diff --git a/README.md b/README.md index 81c372c3..1488d756 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,14 @@ completion manually with ``. set omnifunc=ale#completion#OmniFunc ``` +When working with TypeScript files, ALE supports automatic imports from +external modules. This behavior is disabled by default and can be enabled by +setting: + +```vim +let g:ale_completion_tsserver_autoimport = 1 +``` + See `:help ale-completion` for more information. diff --git a/autoload/ale/code_action.vim b/autoload/ale/code_action.vim new file mode 100644 index 00000000..0af1bb70 --- /dev/null +++ b/autoload/ale/code_action.vim @@ -0,0 +1,163 @@ +" Author: Jerko Steiner +" Description: Code action support for LSP / tsserver + +function! ale#code_action#HandleCodeAction(code_action) abort + let l:current_buffer = bufnr('') + let l:changes = a:code_action.changes + + for l:file_code_edit in l:changes + let l:buf = bufnr(l:file_code_edit.fileName) + + if l:buf != -1 && l:buf != l:current_buffer && getbufvar(l:buf, '&mod') + call ale#util#Execute('echom ''Aborting action, file is unsaved''') + + return + endif + endfor + + for l:file_code_edit in l:changes + call ale#code_action#ApplyChanges( + \ l:file_code_edit.fileName, l:file_code_edit.textChanges) + endfor +endfunction + +function! ale#code_action#ApplyChanges(filename, changes) abort + let l:current_buffer = bufnr('') + " The buffer is used to determine the fileformat, if available. + let l:buffer = bufnr(a:filename) + let l:is_current_buffer = l:buffer > 0 && l:buffer == l:current_buffer + + if l:buffer > 0 + let l:lines = getbufline(l:buffer, 1, '$') + else + let l:lines = readfile(a:filename, 'b') + endif + + if l:is_current_buffer + let l:pos = getpos('.')[1:2] + else + let l:pos = [1, 1] + endif + + " We have to keep track of how many lines we have added, and offset + " changes accordingly. + let l:line_offset = 0 + let l:column_offset = 0 + let l:last_end_line = 0 + + for l:code_edit in a:changes + if l:code_edit.start.line isnot l:last_end_line + let l:column_offset = 0 + endif + + let l:line = l:code_edit.start.line + l:line_offset + let l:column = l:code_edit.start.offset + l:column_offset + let l:end_line = l:code_edit.end.line + l:line_offset + let l:end_column = l:code_edit.end.offset + l:column_offset + let l:text = l:code_edit.newText + + let l:cur_line = l:pos[0] + let l:cur_column = l:pos[1] + + let l:last_end_line = l:end_line + + " 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] + 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]] + endif + + call extend(l:middle, l:insertions[1:]) + let l:middle[-1] .= l:lines[l:end_line - 1][l:end_column - 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:line_offset += l:current_line_offset + 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]) + endfor + + if l:lines[-1] is# '' + call remove(l:lines, -1) + endif + + call ale#util#Writefile(l:buffer, l:lines, a:filename) + + if l:is_current_buffer + call ale#util#Execute(':e!') + call setpos('.', [0, l:pos[0], l:pos[1], 0]) + endif +endfunction + +function! s:UpdateCursor(cursor, start, end, offset) abort + let l:cur_line = a:cursor[0] + let l:cur_column = a:cursor[1] + let l:line = a:start[0] + let l:column = a:start[1] + let l:end_line = a:end[0] + let l:end_column = a:end[1] + let l:line_offset = a:offset[0] + let l:column_offset = a:offset[1] + + if l:end_line < l:cur_line + " both start and end lines are before the cursor. only line offset + " needs to be updated + let l:cur_line += l:line_offset + elseif l:end_line == l:cur_line + " end line is at the same location as cursor, which means + " l:line <= l:cur_line + if l:line < l:cur_line || l:column <= l:cur_column + " updates are happening either before or around the cursor + if l:end_column < l:cur_column + " updates are happening before the cursor, update the + " column offset for cursor + let l:cur_line += l:line_offset + let l:cur_column += l:column_offset + else + " updates are happening around the cursor, move the cursor + " to the end of the changes + let l:cur_line += l:line_offset + let l:cur_column = l:end_column + l:column_offset + endif + " else is not necessary, it means modifications are happening + " after the cursor so no cursor updates need to be done + endif + else + " end line is after the cursor + if l:line < l:cur_line || l:line == l:cur_line && l:column <= l:cur_column + " changes are happening around the cursor, move the cursor + " to the end of the changes + let l:cur_line = l:end_line + l:line_offset + let l:cur_column = l:end_column + l:column_offset + " else is not necesary, it means modifications are happening + " after the cursor so no cursor updates need to be done + endif + endif + + return [l:cur_line, l:cur_column] +endfunction diff --git a/autoload/ale/completion.vim b/autoload/ale/completion.vim index ebf32909..94e85916 100644 --- a/autoload/ale/completion.vim +++ b/autoload/ale/completion.vim @@ -15,6 +15,7 @@ onoremap (ale_show_completion_menu) let g:ale_completion_delay = get(g:, 'ale_completion_delay', 100) let g:ale_completion_excluded_words = get(g:, 'ale_completion_excluded_words', []) let g:ale_completion_max_suggestions = get(g:, 'ale_completion_max_suggestions', 50) +let g:ale_completion_tsserver_autoimport = get(g:, 'ale_completion_tsserver_autoimport', 0) let s:timer_id = -1 let s:last_done_pos = [] @@ -296,7 +297,10 @@ function! ale#completion#ParseTSServerCompletions(response) abort let l:names = [] for l:suggestion in a:response.body - call add(l:names, l:suggestion.name) + call add(l:names, { + \ 'word': l:suggestion.name, + \ 'source': get(l:suggestion, 'source', ''), + \}) endfor return l:names @@ -330,13 +334,22 @@ function! ale#completion#ParseTSServerCompletionEntryDetails(response) abort endif " See :help complete-items - call add(l:results, { + let l:result = { \ 'word': l:suggestion.name, \ 'kind': l:kind, \ 'icase': 1, \ 'menu': join(l:displayParts, ''), + \ 'dup': g:ale_completion_tsserver_autoimport, \ 'info': join(l:documentationParts, ''), - \}) + \} + + if has_key(l:suggestion, 'codeActions') + let l:result.user_data = json_encode({ + \ 'codeActions': l:suggestion.codeActions, + \ }) + endif + + call add(l:results, l:result) endfor let l:names = getbufvar(l:buffer, 'ale_tsserver_completion_names', []) @@ -345,12 +358,12 @@ function! ale#completion#ParseTSServerCompletionEntryDetails(response) abort let l:names_with_details = map(copy(l:results), 'v:val.word') let l:missing_names = filter( \ copy(l:names), - \ 'index(l:names_with_details, v:val) < 0', + \ 'index(l:names_with_details, v:val.word) < 0', \) for l:name in l:missing_names call add(l:results, { - \ 'word': l:name, + \ 'word': l:name.word, \ 'kind': 'v', \ 'icase': 1, \ 'menu': '', @@ -472,13 +485,22 @@ function! ale#completion#HandleTSServerResponse(conn_id, response) abort call setbufvar(l:buffer, 'ale_tsserver_completion_names', l:names) if !empty(l:names) + let l:identifiers = [] + + for l:name in l:names + call add(l:identifiers, { + \ 'name': l:name.word, + \ 'source': get(l:name, 'source', ''), + \}) + endfor + let b:ale_completion_info.request_id = ale#lsp#Send( \ b:ale_completion_info.conn_id, \ ale#lsp#tsserver_message#CompletionEntryDetails( \ l:buffer, \ b:ale_completion_info.line, \ b:ale_completion_info.column, - \ l:names, + \ l:identifiers, \ ), \) endif @@ -525,6 +547,7 @@ function! s:OnReady(linter, lsp_details) abort \ b:ale_completion_info.line, \ b:ale_completion_info.column, \ b:ale_completion_info.prefix, + \ g:ale_completion_tsserver_autoimport, \) else " Send a message saying the buffer has changed first, otherwise @@ -692,6 +715,26 @@ function! ale#completion#Queue() abort let s:timer_id = timer_start(g:ale_completion_delay, function('s:TimerHandler')) endfunction +function! ale#completion#HandleUserData(completed_item) abort + let l:source = get(get(b:, 'ale_completion_info', {}), 'source', '') + + if l:source isnot# 'ale-automatic' && l:source isnot# 'ale-manual' + return + endif + + let l:user_data_json = get(a:completed_item, 'user_data', '') + + if empty(l:user_data_json) + return + endif + + let l:user_data = json_decode(l:user_data_json) + + for l:code_action in get(l:user_data, 'codeActions', []) + call ale#code_action#HandleCodeAction(l:code_action) + endfor +endfunction + function! ale#completion#Done() abort silent! pclose @@ -700,6 +743,10 @@ function! ale#completion#Done() abort let s:last_done_pos = getpos('.')[1:2] endfunction +augroup ALECompletionActions + autocmd CompleteDone * call ale#completion#HandleUserData(v:completed_item) +augroup END + function! s:Setup(enabled) abort augroup ALECompletionGroup autocmd! diff --git a/autoload/ale/lsp.vim b/autoload/ale/lsp.vim index 017096cd..2509174e 100644 --- a/autoload/ale/lsp.vim +++ b/autoload/ale/lsp.vim @@ -37,6 +37,7 @@ function! ale#lsp#Register(executable_or_address, project, init_options) abort \ 'init_queue': [], \ 'capabilities': { \ 'hover': 0, + \ 'rename': 0, \ 'references': 0, \ 'completion': 0, \ 'completion_trigger_characters': [], @@ -199,6 +200,10 @@ function! s:UpdateCapabilities(conn, capabilities) abort let a:conn.capabilities.references = 1 endif + if get(a:capabilities, 'renameProvider') is v:true + let a:conn.capabilities.rename = 1 + endif + if !empty(get(a:capabilities, 'completionProvider')) let a:conn.capabilities.completion = 1 endif @@ -317,6 +322,7 @@ function! ale#lsp#MarkConnectionAsTsserver(conn_id) abort let l:conn.capabilities.completion_trigger_characters = ['.'] let l:conn.capabilities.definition = 1 let l:conn.capabilities.symbol_search = 1 + let l:conn.capabilities.rename = 1 endfunction function! s:SendInitMessage(conn) abort diff --git a/autoload/ale/lsp/message.vim b/autoload/ale/lsp/message.vim index b6b14a22..5b0cb8b7 100644 --- a/autoload/ale/lsp/message.vim +++ b/autoload/ale/lsp/message.vim @@ -162,3 +162,13 @@ function! ale#lsp#message#DidChangeConfiguration(buffer, config) abort \ 'settings': a:config, \}] endfunction + +function! ale#lsp#message#Rename(buffer, line, column, new_name) abort + return [0, 'textDocument/rename', { + \ 'textDocument': { + \ 'uri': ale#path#ToURI(expand('#' . a:buffer . ':p')), + \ }, + \ 'position': {'line': a:line - 1, 'character': a:column - 1}, + \ 'newName': a:new_name, + \}] +endfunction diff --git a/autoload/ale/lsp/tsserver_message.vim b/autoload/ale/lsp/tsserver_message.vim index d6919516..b9fafaa0 100644 --- a/autoload/ale/lsp/tsserver_message.vim +++ b/autoload/ale/lsp/tsserver_message.vim @@ -36,12 +36,14 @@ function! ale#lsp#tsserver_message#Geterr(buffer) abort return [1, 'ts@geterr', {'files': [expand('#' . a:buffer . ':p')]}] endfunction -function! ale#lsp#tsserver_message#Completions(buffer, line, column, prefix) abort +function! ale#lsp#tsserver_message#Completions( +\ buffer, line, column, prefix, include_external) abort return [0, 'ts@completions', { \ 'line': a:line, \ 'offset': a:column, \ 'file': expand('#' . a:buffer . ':p'), \ 'prefix': a:prefix, + \ 'includeExternalModuleExports': a:include_external, \}] endfunction @@ -77,3 +79,27 @@ function! ale#lsp#tsserver_message#Quickinfo(buffer, line, column) abort \ 'file': expand('#' . a:buffer . ':p'), \}] endfunction + +function! ale#lsp#tsserver_message#Rename( +\ buffer, line, column, find_in_comments, find_in_strings) abort + return [0, 'ts@rename', { + \ 'line': a:line, + \ 'offset': a:column, + \ 'file': expand('#' . a:buffer . ':p'), + \ 'arguments': { + \ 'findInComments': a:find_in_comments, + \ 'findInStrings': a:find_in_strings, + \ } + \}] +endfunction + +function! ale#lsp#tsserver_message#OrganizeImports(buffer) abort + return [0, 'ts@organizeImports', { + \ 'scope': { + \ 'type': 'file', + \ 'args': { + \ 'file': expand('#' . a:buffer . ':p'), + \ }, + \ }, + \}] +endfunction diff --git a/autoload/ale/organize_imports.vim b/autoload/ale/organize_imports.vim new file mode 100644 index 00000000..bc9b829e --- /dev/null +++ b/autoload/ale/organize_imports.vim @@ -0,0 +1,59 @@ +" Author: Jerko Steiner +" Description: Organize imports support for tsserver +" +function! ale#organize_imports#HandleTSServerResponse(conn_id, response) abort + if get(a:response, 'command', '') isnot# 'organizeImports' + return + endif + + if get(a:response, 'success', v:false) isnot v:true + return + endif + + let l:file_code_edits = a:response.body + + call ale#code_action#HandleCodeAction({ + \ 'description': 'Organize Imports', + \ 'changes': l:file_code_edits, + \}) +endfunction + +function! s:OnReady(linter, lsp_details) abort + let l:id = a:lsp_details.connection_id + + if a:linter.lsp isnot# 'tsserver' + call ale#util#Execute('echom ''OrganizeImports currently only works with tsserver''') + + return + endif + + let l:buffer = a:lsp_details.buffer + + let l:Callback = function('ale#organize_imports#HandleTSServerResponse') + + call ale#lsp#RegisterCallback(l:id, l:Callback) + + let l:message = ale#lsp#tsserver_message#OrganizeImports(l:buffer) + + let l:request_id = ale#lsp#Send(l:id, l:message) +endfunction + +function! s:OrganizeImports(linter) abort + let l:buffer = bufnr('') + let [l:line, l:column] = getpos('.')[1:2] + + if a:linter.lsp isnot# 'tsserver' + let l:column = min([l:column, len(getline(l:line))]) + endif + + let l:Callback = function('s:OnReady') + call ale#lsp_linter#StartLSP(l:buffer, a:linter, l:Callback) +endfunction + +function! ale#organize_imports#Execute() abort + for l:linter in ale#linter#Get(&filetype) + if !empty(l:linter.lsp) + call s:OrganizeImports(l:linter) + endif + endfor +endfunction diff --git a/autoload/ale/rename.vim b/autoload/ale/rename.vim new file mode 100644 index 00000000..02b7b579 --- /dev/null +++ b/autoload/ale/rename.vim @@ -0,0 +1,225 @@ +" Author: Jerko Steiner +" Description: Rename symbol support for LSP / tsserver + +let s:rename_map = {} + +" Used to get the rename map in tests. +function! ale#rename#GetMap() abort + return deepcopy(s:rename_map) +endfunction + +" Used to set the rename map in tests. +function! ale#rename#SetMap(map) abort + let s:rename_map = a:map +endfunction + +function! ale#rename#ClearLSPData() abort + let s:rename_map = {} +endfunction + +let g:ale_rename_tsserver_find_in_comments = get(g:, 'ale_rename_tsserver_find_in_comments') +let g:ale_rename_tsserver_find_in_strings = get(g:, 'ale_rename_tsserver_find_in_strings') + +function! s:message(message) abort + call ale#util#Execute('echom ' . string(a:message)) +endfunction + +function! ale#rename#HandleTSServerResponse(conn_id, response) abort + if get(a:response, 'command', '') isnot# 'rename' + return + endif + + if !has_key(s:rename_map, a:response.request_seq) + return + endif + + let l:old_name = s:rename_map[a:response.request_seq].old_name + let l:new_name = s:rename_map[a:response.request_seq].new_name + call remove(s:rename_map, a:response.request_seq) + + if get(a:response, 'success', v:false) is v:false + let l:message = get(a:response, 'message', 'unknown') + call s:message('Error renaming "' . l:old_name . '" to: "' . l:new_name + \ . '". Reason: ' . l:message) + + return + endif + + let l:changes = [] + + for l:response_item in a:response.body.locs + let l:filename = l:response_item.file + let l:text_changes = [] + + for l:loc in l:response_item.locs + call add(l:text_changes, { + \ 'start': { + \ 'line': l:loc.start.line, + \ 'offset': l:loc.start.offset, + \ }, + \ 'end': { + \ 'line': l:loc.end.line, + \ 'offset': l:loc.end.offset, + \ }, + \ 'newText': l:new_name, + \}) + endfor + + call add(l:changes, { + \ 'fileName': l:filename, + \ 'textChanges': l:text_changes, + \}) + endfor + + if empty(l:changes) + call s:message('Error renaming "' . l:old_name . '" to: "' . l:new_name . '"') + + return + endif + + call ale#code_action#HandleCodeAction({ + \ 'description': 'rename', + \ 'changes': l:changes, + \}) +endfunction + +function! ale#rename#HandleLSPResponse(conn_id, response) abort + if has_key(a:response, 'id') + \&& has_key(s:rename_map, a:response.id) + call remove(s:rename_map, a:response.id) + + if !has_key(a:response, 'result') + call s:message('No rename result received from server') + + return + endif + + let l:workspace_edit = a:response.result + + if !has_key(l:workspace_edit, 'changes') || empty(l:workspace_edit.changes) + call s:message('No changes received from server') + + return + endif + + let l:changes = [] + + for l:file_name in keys(l:workspace_edit.changes) + let l:text_edits = l:workspace_edit.changes[l:file_name] + let l:text_changes = [] + + for l:edit in l:text_edits + let l:range = l:edit.range + let l:new_text = l:edit.newText + + call add(l:text_changes, { + \ 'start': { + \ 'line': l:range.start.line + 1, + \ 'offset': l:range.start.character + 1, + \ }, + \ 'end': { + \ 'line': l:range.end.line + 1, + \ 'offset': l:range.end.character + 1, + \ }, + \ 'newText': l:new_text, + \}) + endfor + + call add(l:changes, { + \ 'fileName': ale#path#FromURI(l:file_name), + \ 'textChanges': l:text_changes, + \}) + endfor + + call ale#code_action#HandleCodeAction({ + \ 'description': 'rename', + \ 'changes': l:changes, + \}) + endif +endfunction + +function! s:OnReady(line, column, old_name, new_name, linter, lsp_details) abort + let l:id = a:lsp_details.connection_id + + if !ale#lsp#HasCapability(l:id, 'rename') + return + endif + + let l:buffer = a:lsp_details.buffer + + let l:Callback = a:linter.lsp is# 'tsserver' + \ ? function('ale#rename#HandleTSServerResponse') + \ : function('ale#rename#HandleLSPResponse') + + call ale#lsp#RegisterCallback(l:id, l:Callback) + + if a:linter.lsp is# 'tsserver' + let l:message = ale#lsp#tsserver_message#Rename( + \ l:buffer, + \ a:line, + \ a:column, + \ g:ale_rename_tsserver_find_in_comments, + \ g:ale_rename_tsserver_find_in_strings, + \) + else + " Send a message saying the buffer has changed first, or the + " rename position probably won't make sense. + call ale#lsp#NotifyForChanges(l:id, l:buffer) + + let l:message = ale#lsp#message#Rename( + \ l:buffer, + \ a:line, + \ a:column, + \ a:new_name + \) + endif + + let l:request_id = ale#lsp#Send(l:id, l:message) + + let s:rename_map[l:request_id] = { + \ 'new_name': a:new_name, + \ 'old_name': a:old_name, + \} +endfunction + +function! s:ExecuteRename(linter, old_name, new_name) abort + let l:buffer = bufnr('') + let [l:line, l:column] = getpos('.')[1:2] + + if a:linter.lsp isnot# 'tsserver' + let l:column = min([l:column, len(getline(l:line))]) + endif + + let l:Callback = function( + \ 's:OnReady', [l:line, l:column, a:old_name, a:new_name]) + call ale#lsp_linter#StartLSP(l:buffer, a:linter, l:Callback) +endfunction + +function! ale#rename#Execute() abort + let l:lsp_linters = [] + + for l:linter in ale#linter#Get(&filetype) + if !empty(l:linter.lsp) + call add(l:lsp_linters, l:linter) + endif + endfor + + if empty(l:lsp_linters) + call s:message('No active LSPs') + + return + endif + + let l:old_name = expand('') + let l:new_name = ale#util#Input('New name: ', l:old_name) + + if empty(l:new_name) + call s:message('New name cannot be empty!') + + return + endif + + for l:lsp_linter in l:lsp_linters + call s:ExecuteRename(l:lsp_linter, l:old_name, l:new_name) + endfor +endfunction diff --git a/autoload/ale/util.vim b/autoload/ale/util.vim index e7563608..99cd856a 100644 --- a/autoload/ale/util.vim +++ b/autoload/ale/util.vim @@ -477,3 +477,6 @@ function! ale#util#FindItemAtCursor(buffer) abort return [l:info, l:loc] endfunction +function! ale#util#Input(message, value) abort + return input(a:message, a:value) +endfunction diff --git a/doc/ale.txt b/doc/ale.txt index 43e124a2..aa8dbafe 100644 --- a/doc/ale.txt +++ b/doc/ale.txt @@ -376,6 +376,10 @@ The |ALEComplete| command can be used to show completion suggestions manually, even when |g:ale_completion_enabled| is set to `0`. For manually requesting completion information with Deoplete, consult Deoplete's documentation. +When working with TypeScript files, ALE by can support automatic imports +from external modules. This behavior can be enabled by setting the +|g:ale_completion_tsserver_autoimport| variable to `1`. + *ale-completion-completeopt-bug* ALE Automatic completion implementation replaces |completeopt| before opening @@ -597,6 +601,16 @@ b:ale_completion_enabled *b:ale_completion_enabled* See |ale-completion| +g:ale_completion_tsserver_autoimport *g:ale_completion_tsserver_autoimport* + + Type: Number + Default: `0` + + When this option is set to `0`, ALE will not try to automatically import + completion results from external modules. It can be enabled by setting it + to `1`. + + g:ale_completion_excluded_words *g:ale_completion_excluded_words* *b:ale_completion_excluded_words* Type: |List| @@ -1317,6 +1331,27 @@ g:ale_pattern_options_enabled *g:ale_pattern_options_enabled* will not set buffer variables per |g:ale_pattern_options|. +g:ale_rename_tsserver_find_in_comments *g:ale_rename_tsserver_find_in_comments* + + Type: |Number| + Default: `0` + + If enabled, this option will tell tsserver to find and replace text in + comments when calling |ALERename|. It can be enabled by settings the value + to `1`. + + +g:ale_rename_tsserver_find_in_strings *g:ale_rename_tsserver_find_in_strings* + + + Type: |Number| + Default: `0` + + If enabled, this option will tell tsserver to find and replace text in + strings when calling |ALERename|. It can be enabled by settings the value to + `1`. + + g:ale_set_balloons *g:ale_set_balloons* *b:ale_set_balloons* @@ -2543,6 +2578,18 @@ ALEHover *ALEHover* A plug mapping `(ale_hover)` is defined for this command. +ALEOrganizeImports *ALEOrganizeImports* + + Organize imports using tsserver. Currently not implemented for LSPs. + + +ALERename *ALERename* + + Rename a symbol using TypeScript server or Language Server. + + The user will be prompted for a new name. + + ALESymbolSearch `` *ALESymbolSearch* Search for symbols in the workspace, taken from any available LSP linters. diff --git a/plugin/ale.vim b/plugin/ale.vim index 6262a7c4..1912a9c0 100644 --- a/plugin/ale.vim +++ b/plugin/ale.vim @@ -221,6 +221,12 @@ command! -nargs=1 ALESymbolSearch :call ale#symbol#Search() command! -bar ALEComplete :call ale#completion#GetCompletions('ale-manual') +" Rename symbols using tsserver and LSP +command! -bar ALERename :call ale#rename#Execute() + +" Organize import statements using tsserver +command! -bar ALEOrganizeImports :call ale#organize_imports#Execute() + " mappings for commands nnoremap (ale_previous) :ALEPrevious nnoremap (ale_previous_wrap) :ALEPreviousWrap @@ -259,6 +265,7 @@ nnoremap (ale_find_references) :ALEFindReferences nnoremap (ale_hover) :ALEHover nnoremap (ale_documentation) :ALEDocumentation inoremap (ale_complete) :ALEComplete +nnoremap (ale_rename) :ALERename " Set up autocmd groups now. call ale#events#Init() diff --git a/test/completion/test_completion_events.vader b/test/completion/test_completion_events.vader index 5672f8e5..e06ac98b 100644 --- a/test/completion/test_completion_events.vader +++ b/test/completion/test_completion_events.vader @@ -47,6 +47,14 @@ Before: AssertEqual a:expect_success, g:get_completions_called endfunction + let g:handle_code_action_called = 0 + function! MockHandleCodeAction() abort + " delfunction! ale#code_action#HandleCodeAction + function! ale#code_action#HandleCodeAction(action) abort + let g:handle_code_action_called += 1 + endfunction + endfunction + After: Restore @@ -54,6 +62,7 @@ After: unlet! g:output unlet! g:fake_mode unlet! g:get_completions_called + unlet! g:handle_code_action_called unlet! b:ale_old_omnifunc unlet! b:ale_old_completeopt unlet! b:ale_completion_info @@ -61,6 +70,8 @@ After: unlet! b:ale_complete_done_time delfunction CheckCompletionCalled + delfunction ale#code_action#HandleCodeAction + delfunction MockHandleCodeAction if exists('*CompleteCallback') delfunction CompleteCallback @@ -77,6 +88,7 @@ After: endfunction runtime autoload/ale/completion.vim + runtime autoload/ale/code_action.vim runtime autoload/ale/util.vim Execute(ale#completion#GetCompletions should be called when the cursor position stays the same): @@ -385,3 +397,44 @@ Execute(Running the normal mode keybind should reset the settings): AssertEqual 'menu', &l:completeopt Assert !has_key(b:, 'ale_old_omnifunc') Assert !has_key(b:, 'ale_old_completeopt') + +Execute(HandleUserData should call ale#code_action#HandleCodeAction): + let b:ale_completion_info = {'source': 'ale-manual'} + call MockHandleCodeAction() + + call ale#completion#HandleUserData({}) + AssertEqual g:handle_code_action_called, 0 + + call ale#completion#HandleUserData({ + \ 'user_data': '' + \}) + AssertEqual g:handle_code_action_called, 0 + + call ale#completion#HandleUserData({ + \ 'user_data': '{}' + \}) + AssertEqual g:handle_code_action_called, 0 + + call ale#completion#HandleUserData({ + \ 'user_data': '{"codeActions": []}' + \}) + AssertEqual g:handle_code_action_called, 0 + + call ale#completion#HandleUserData({ + \ 'user_data': '{"codeActions": [{"description":"", "changes": []}]}' + \}) + AssertEqual g:handle_code_action_called, 1 + + let b:ale_completion_info = {'source': 'ale-automatic'} + call ale#completion#HandleUserData({ + \ 'user_data': '{"codeActions": [{"description":"", "changes": []}]}' + \}) + AssertEqual g:handle_code_action_called, 2 + +Execute(ale#code_action#HandleCodeAction should not be called when when source is not ALE): + call MockHandleCodeAction() + let b:ale_completion_info = {'source': 'syntastic'} + call ale#completion#HandleUserData({ + \ 'user_data': '{"codeActions": [{"description":"", "changes": []}]}' + \}) + AssertEqual g:handle_code_action_called, 0 diff --git a/test/completion/test_lsp_completion_messages.vader b/test/completion/test_lsp_completion_messages.vader index 6bd241a8..b997ac86 100644 --- a/test/completion/test_lsp_completion_messages.vader +++ b/test/completion/test_lsp_completion_messages.vader @@ -116,7 +116,13 @@ Execute(The right message should be sent for the initial tsserver request): \ string(g:Callback) " We should send the right message. AssertEqual - \ [[0, 'ts@completions', {'file': expand('%:p'), 'line': 1, 'offset': 3, 'prefix': 'fo'}]], + \ [[0, 'ts@completions', { + \ 'file': expand('%:p'), + \ 'line': 1, + \ 'offset': 3, + \ 'prefix': 'fo', + \ 'includeExternalModuleExports': g:ale_completion_tsserver_autoimport, + \ }]], \ g:message_list " We should set up the completion info correctly. AssertEqual @@ -151,7 +157,7 @@ Execute(The right message sent to the tsserver LSP when the first completion mes \ 'body': [ \ {'name': 'Baz'}, \ {'name': 'dingDong'}, - \ {'name': 'Foo'}, + \ {'name': 'Foo', 'source': '/path/to/foo.ts'}, \ {'name': 'FooBar'}, \ {'name': 'frazzle'}, \ {'name': 'FFS'}, @@ -160,8 +166,16 @@ Execute(The right message sent to the tsserver LSP when the first completion mes " We should save the names we got in the buffer, as TSServer doesn't return " details for every name. - AssertEqual - \ ['Foo', 'FooBar', 'frazzle'], + AssertEqual [{ + \ 'word': 'Foo', + \ 'source': '/path/to/foo.ts', + \ }, { + \ 'word': 'FooBar', + \ 'source': '', + \ }, { + \ 'word': 'frazzle', + \ 'source': '', + \}], \ get(b:, 'ale_tsserver_completion_names', []) " The entry details messages should have been sent. @@ -171,7 +185,16 @@ Execute(The right message sent to the tsserver LSP when the first completion mes \ 'ts@completionEntryDetails', \ { \ 'file': expand('%:p'), - \ 'entryNames': ['Foo', 'FooBar', 'frazzle'], + \ 'entryNames': [{ + \ 'name': 'Foo', + \ 'source': '/path/to/foo.ts', + \ }, { + \ 'name': 'FooBar', + \ 'source': '', + \ }, { + \ 'name': 'frazzle', + \ 'source': '', + \ }], \ 'offset': 1, \ 'line': 1, \ }, diff --git a/test/completion/test_tsserver_completion_parsing.vader b/test/completion/test_tsserver_completion_parsing.vader index dbc4f9e2..02f287a9 100644 --- a/test/completion/test_tsserver_completion_parsing.vader +++ b/test/completion/test_tsserver_completion_parsing.vader @@ -6,10 +6,24 @@ Execute(TypeScript completions responses should be parsed correctly): \ ale#completion#ParseTSServerCompletions({ \ 'body': [], \}) - AssertEqual ['foo', 'bar', 'baz'], + AssertEqual + \ [ + \ { + \ 'word': 'foo', + \ 'source': '/path/to/foo.ts', + \ }, + \ { + \ 'word': 'bar', + \ 'source': '', + \ }, + \ { + \ 'word': 'baz', + \ 'source': '', + \ } + \ ], \ ale#completion#ParseTSServerCompletions({ \ 'body': [ - \ {'name': 'foo'}, + \ {'name': 'foo', 'source': '/path/to/foo.ts'}, \ {'name': 'bar'}, \ {'name': 'baz'}, \ ], @@ -24,6 +38,7 @@ Execute(TypeScript completion details responses should be parsed correctly): \ 'info': '', \ 'kind': 'f', \ 'icase': 1, + \ 'dup': g:ale_completion_tsserver_autoimport, \ }, \ { \ 'word': 'def', @@ -31,6 +46,7 @@ Execute(TypeScript completion details responses should be parsed correctly): \ 'info': 'foo bar baz', \ 'kind': 'f', \ 'icase': 1, + \ 'dup': g:ale_completion_tsserver_autoimport, \ }, \ { \ 'word': 'ghi', @@ -38,6 +54,7 @@ Execute(TypeScript completion details responses should be parsed correctly): \ 'info': '', \ 'kind': 'f', \ 'icase': 1, + \ 'dup': g:ale_completion_tsserver_autoimport, \ }, \ ], \ ale#completion#ParseTSServerCompletionEntryDetails({ @@ -96,7 +113,10 @@ Execute(TypeScript completion details responses should be parsed correctly): \}) Execute(Entries without details should be included in the responses): - let b:ale_tsserver_completion_names = ['xyz'] + let b:ale_tsserver_completion_names = [{ + \ 'word': 'xyz', + \ 'source': '/path/to/xyz.ts', + \ }] AssertEqual \ [ @@ -106,6 +126,13 @@ Execute(Entries without details should be included in the responses): \ 'info': '', \ 'kind': 'f', \ 'icase': 1, + \ 'user_data': json_encode({ + \ 'codeActions': [{ + \ 'description': 'abc action', + \ 'changes': [], + \ }], + \ }), + \ 'dup': g:ale_completion_tsserver_autoimport, \ }, \ { \ 'word': 'def', @@ -113,6 +140,7 @@ Execute(Entries without details should be included in the responses): \ 'info': 'foo bar baz', \ 'kind': 'f', \ 'icase': 1, + \ 'dup': g:ale_completion_tsserver_autoimport, \ }, \ { \ 'word': 'xyz', @@ -139,6 +167,10 @@ Execute(Entries without details should be included in the responses): \ {'text': ' '}, \ {'text': 'number'}, \ ], + \ 'codeActions': [{ + \ 'description': 'abc action', + \ 'changes': [], + \ }], \ }, \ { \ 'name': 'def', diff --git a/test/lsp/test_lsp_client_messages.vader b/test/lsp/test_lsp_client_messages.vader index 90a20832..bc91bf68 100644 --- a/test/lsp/test_lsp_client_messages.vader +++ b/test/lsp/test_lsp_client_messages.vader @@ -275,9 +275,10 @@ Execute(ale#lsp#tsserver_message#Completions() should return correct messages): \ 'line': 347, \ 'offset': 12, \ 'prefix': 'abc', + \ 'includeExternalModuleExports': 1, \ } \ ], - \ ale#lsp#tsserver_message#Completions(bufnr(''), 347, 12, 'abc') + \ ale#lsp#tsserver_message#Completions(bufnr(''), 347, 12, 'abc', 1) Execute(ale#lsp#tsserver_message#CompletionEntryDetails() should return correct messages): AssertEqual diff --git a/test/lsp/test_other_initialize_message_handling.vader b/test/lsp/test_other_initialize_message_handling.vader index 0372765d..6473e283 100644 --- a/test/lsp/test_other_initialize_message_handling.vader +++ b/test/lsp/test_other_initialize_message_handling.vader @@ -17,6 +17,7 @@ Before: \ 'init_queue': [], \ 'capabilities': { \ 'hover': 0, + \ 'rename': 0, \ 'references': 0, \ 'completion': 0, \ 'completion_trigger_characters': [], @@ -100,6 +101,7 @@ Execute(Capabilities should bet set up correctly): \ 'hover': 1, \ 'definition': 1, \ 'symbol_search': 1, + \ 'rename': 1, \ }, \ b:conn.capabilities AssertEqual [[1, 'initialized', {}]], g:message_list @@ -110,7 +112,7 @@ Execute(Disabled capabilities should be recognised correctly): \ 'id': 1, \ 'result': { \ 'capabilities': { - \ 'renameProvider': v:true, + \ 'renameProvider': v:false, \ 'executeCommandProvider': { \ 'commands': [], \ }, @@ -143,6 +145,7 @@ Execute(Disabled capabilities should be recognised correctly): \ 'hover': 0, \ 'definition': 0, \ 'symbol_search': 0, + \ 'rename': 0, \ }, \ b:conn.capabilities AssertEqual [[1, 'initialized', {}]], g:message_list diff --git a/test/test_autocmd_commands.vader b/test/test_autocmd_commands.vader index 241e7d3e..355b4c77 100644 --- a/test/test_autocmd_commands.vader +++ b/test/test_autocmd_commands.vader @@ -188,6 +188,10 @@ Execute (ALECleanupGroup should include the right commands): \], CheckAutocmd('ALECleanupGroup') endif +Execute(ALECompletionActions should always be set up): + AssertEqual [ + \ 'CompleteDone * call ale#completion#HandleUserData(v:completed_item)', + \], CheckAutocmd('ALECompletionActions') Execute(Enabling completion should set up autocmd events correctly): let g:ale_completion_enabled = 0 diff --git a/test/test_code_action.vader b/test/test_code_action.vader new file mode 100644 index 00000000..ffaca630 --- /dev/null +++ b/test/test_code_action.vader @@ -0,0 +1,334 @@ +Before: + runtime autoload/ale/code_action.vim + runtime autoload/ale/util.vim + + let g:file1 = tempname() + let g:file2 = tempname() + let g:test = {} + + let g:test.create_change = {line, offset, end_line, end_offset, value -> + \{ + \ 'changes': [{ + \ 'fileName': g:file1, + \ 'textChanges': [{ + \ 'start': { + \ 'line': line, + \ 'offset': offset, + \ }, + \ 'end': { + \ 'line': end_line, + \ 'offset': end_offset, + \ }, + \ 'newText': value, + \ }], + \ }] + \}} + + function! WriteFileAndEdit() abort + let g:test.text = [ + \ 'class Name {', + \ ' value: string', + \ '}', + \] + call writefile(g:test.text, g:file1, 'S') + execute 'edit ' . g:file1 + endfunction! + +After: + " Close the extra buffers if we opened it. + if bufnr(g:file1) != -1 + execute ':bp | :bd ' . bufnr(g:file1) + endif + if bufnr(g:file2) != -1 + execute ':bp | :bd ' . bufnr(g:file2) + endif + + if filereadable(g:file1) + call delete(g:file1) + endif + if filereadable(g:file2) + call delete(g:file2) + endif + + unlet g:file1 + unlet g:file2 + unlet g:test + delfunction WriteFileAndEdit + + runtime autoload/ale/code_action.vim + runtime autoload/ale/util.vim + + +Execute(It should modify and save multiple files): + call writefile([ + \ 'class Name {', + \ ' value: string', + \ '}', + \ '', + \ 'class B {', + \ ' constructor(readonly a: Name) {}', + \ '}' + \], g:file1, 'S') + call writefile([ + \ 'import A from "A"', + \ 'import {', + \ ' B,', + \ ' C,', + \ '} from "module"', + \ 'import D from "D"', + \], g:file2, 'S') + + call ale#code_action#HandleCodeAction({ + \ 'changes': [{ + \ 'fileName': g:file1, + \ 'textChanges': [{ + \ 'start': { + \ 'line': 1, + \ 'offset': 7, + \ }, + \ 'end': { + \ 'line': 1, + \ 'offset': 11, + \ }, + \ 'newText': 'Value', + \ }, { + \ 'start': { + \ 'line': 6, + \ 'offset': 27, + \ }, + \ 'end': { + \ 'line': 6, + \ 'offset': 31, + \ }, + \ 'newText': 'Value', + \ }], + \ }, { + \ 'fileName': g:file2, + \ 'textChanges': [{ + \ 'start': { + \ 'line': 2, + \ 'offset': 1, + \ }, + \ 'end': { + \ 'line': 6, + \ 'offset': 1, + \ }, + \ 'newText': "import {A, B} from 'module'\n\n", + \ }] + \ }], + \}) + + AssertEqual [ + \ 'class Value {', + \ ' value: string', + \ '}', + \ '', + \ 'class B {', + \ ' constructor(readonly a: Value) {}', + \ '}', + \ '', + \], readfile(g:file1, 'b') + + AssertEqual [ + \ 'import A from "A"', + \ 'import {A, B} from ''module''', + \ '', + \ 'import D from "D"', + \ '', + \], readfile(g:file2, 'b') + + +Execute(Beginning of file can be modified): + let g:test.text = [ + \ 'class Name {', + \ ' value: string', + \ '}', + \] + call writefile(g:test.text, g:file1, 'S') + + call ale#code_action#HandleCodeAction({ + \ 'changes': [{ + \ 'fileName': g:file1, + \ 'textChanges': [{ + \ 'start': { + \ 'line': 1, + \ 'offset': 1, + \ }, + \ 'end': { + \ 'line': 1, + \ 'offset': 1, + \ }, + \ 'newText': "type A: string\ntype B: number\n", + \ }], + \ }] + \}) + + AssertEqual [ + \ 'type A: string', + \ 'type B: number', + \] + g:test.text + [''], readfile(g:file1, 'b') + + +Execute(End of file can be modified): + let g:test.text = [ + \ 'class Name {', + \ ' value: string', + \ '}', + \] + call writefile(g:test.text, g:file1, 'S') + + call ale#code_action#HandleCodeAction({ + \ 'changes': [{ + \ 'fileName': g:file1, + \ 'textChanges': [{ + \ 'start': { + \ 'line': 4, + \ 'offset': 1, + \ }, + \ 'end': { + \ 'line': 4, + \ 'offset': 1, + \ }, + \ 'newText': "type A: string\ntype B: number\n", + \ }], + \ }] + \}) + + AssertEqual g:test.text + [ + \ 'type A: string', + \ 'type B: number', + \ '', + \], readfile(g:file1, 'b') + + +Execute(Current buffer contents will be reloaded): + let g:test.text = [ + \ 'class Name {', + \ ' value: string', + \ '}', + \] + call writefile(g:test.text, g:file1, 'S') + + execute 'edit ' . g:file1 + let g:test.buffer = bufnr(g:file1) + + call ale#code_action#HandleCodeAction({ + \ 'changes': [{ + \ 'fileName': g:file1, + \ 'textChanges': [{ + \ 'start': { + \ 'line': 1, + \ 'offset': 1, + \ }, + \ 'end': { + \ 'line': 1, + \ 'offset': 1, + \ }, + \ 'newText': "type A: string\ntype B: number\n", + \ }], + \ }] + \}) + + AssertEqual [ + \ 'type A: string', + \ 'type B: number', + \] + g:test.text + [''], readfile(g:file1, 'b') + + AssertEqual [ + \ 'type A: string', + \ 'type B: number', + \] + g:test.text, getbufline(g:test.buffer, 1, '$') + + +# Tests for cursor repositioning. In comments `=` designates change range, and +# `C` cursor position + +# C === +Execute(Cursor will not move when it is before text change): + call WriteFileAndEdit() + let g:test.changes = g:test.create_change(2, 3, 2, 8, 'value2') + + call setpos('.', [0, 1, 1, 0]) + call ale#code_action#HandleCodeAction(g:test.changes) + AssertEqual [1, 1], getpos('.')[1:2] + + call setpos('.', [0, 2, 2, 0]) + call ale#code_action#HandleCodeAction(g:test.changes) + AssertEqual [2, 2], getpos('.')[1:2] + +# ====C==== +Execute(Cursor column will move to the change end when cursor between start/end): + let g:test.changes = g:test.create_change(2, 3, 2, 8, 'value2') + + for r in range(3, 8) + call WriteFileAndEdit() + call setpos('.', [0, 2, r, 0]) + AssertEqual ' value: string', getline('.') + call ale#code_action#HandleCodeAction(g:test.changes) + AssertEqual ' value2: string', getline('.') + AssertEqual [2, 9], getpos('.')[1:2] + endfor + + +# ====C +Execute(Cursor column will move back when new text is shorter): + call WriteFileAndEdit() + call setpos('.', [0, 2, 8, 0]) + AssertEqual ' value: string', getline('.') + call ale#code_action#HandleCodeAction(g:test.create_change(2, 3, 2, 8, 'val')) + AssertEqual ' val: string', getline('.') + AssertEqual [2, 6], getpos('.')[1:2] + + +# ==== C +Execute(Cursor column will move forward when new text is longer): + call WriteFileAndEdit() + + call setpos('.', [0, 2, 8, 0]) + AssertEqual ' value: string', getline('.') + call ale#code_action#HandleCodeAction(g:test.create_change(2, 3, 2, 8, 'longValue')) + AssertEqual ' longValue: string', getline('.') + AssertEqual [2, 12], getpos('.')[1:2] + +# ========= +# = +# C +Execute(Cursor line will move when updates are happening on lines above): + call WriteFileAndEdit() + call setpos('.', [0, 3, 1, 0]) + AssertEqual '}', getline('.') + call ale#code_action#HandleCodeAction(g:test.create_change(1, 1, 2, 1, "test\ntest\n")) + AssertEqual '}', getline('.') + AssertEqual [4, 1], getpos('.')[1:2] + + +# ========= +# =C +Execute(Cursor line and column will move when change on lines above and just before cursor column): + call WriteFileAndEdit() + call setpos('.', [0, 2, 2, 0]) + AssertEqual ' value: string', getline('.') + call ale#code_action#HandleCodeAction(g:test.create_change(1, 1, 2, 1, "test\ntest\n123")) + AssertEqual '123 value: string', getline('.') + AssertEqual [3, 5], getpos('.')[1:2] + +# ========= +# ======C== +# = +Execute(Cursor line and column will move at the end of changes): + call WriteFileAndEdit() + call setpos('.', [0, 2, 10, 0]) + AssertEqual ' value: string', getline('.') + call ale#code_action#HandleCodeAction(g:test.create_change(1, 1, 3, 1, "test\n")) + AssertEqual '}', getline('.') + AssertEqual [2, 1], getpos('.')[1:2] + +# C == +# === +Execute(Cursor will not move when changes happening on lines >= cursor, but after cursor): + call WriteFileAndEdit() + call setpos('.', [0, 2, 3, 0]) + AssertEqual ' value: string', getline('.') + call ale#code_action#HandleCodeAction(g:test.create_change(2, 10, 3, 1, "number\n")) + AssertEqual ' value: number', getline('.') + AssertEqual [2, 3], getpos('.')[1:2] diff --git a/test/test_organize_imports.vader b/test/test_organize_imports.vader new file mode 100644 index 00000000..137326a9 --- /dev/null +++ b/test/test_organize_imports.vader @@ -0,0 +1,171 @@ +Before: + call ale#test#SetDirectory('/testplugin/test') + call ale#test#SetFilename('dummy.txt') + + 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/organize_imports.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) abort + let g:handle_code_action_called = 1 + call add(g:code_actions, a:code_action) + endfunction + +After: + if g:conn_id isnot v:null + call ale#lsp#RemoveConnectionWithID(g:conn_id) + endif + + call ale#references#SetMap({}) + 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/organize_imports.vim + runtime autoload/ale/code_action.vim + +Execute(Other messages for the tsserver handler should be ignored): + call ale#organize_imports#HandleTSServerResponse(1, {'command': 'foo'}) + AssertEqual g:handle_code_action_called, 0 + +Execute(Failed organizeImports responses should be handled correctly): + call ale#organize_imports#HandleTSServerResponse( + \ 1, + \ {'command': 'organizeImports', 'request_seq': 3} + \) + AssertEqual g:handle_code_action_called, 0 + +Execute(Code actions from tsserver should be handled): + call ale#organize_imports#HandleTSServerResponse(1, { + \ 'command': 'organizeImports', + \ 'request_seq': 3, + \ 'success': v:true, + \ 'body': [], + \}) + AssertEqual g:handle_code_action_called, 1 + AssertEqual [{ + \ 'description': 'Organize Imports', + \ 'changes': [], + \}], g:code_actions + +Given typescript(Some typescript file): + foo + somelongerline + bazxyzxyzxyz + +Execute(tsserver organize imports requests should be sent): + call ale#linter#Reset() + runtime ale_linters/typescript/tsserver.vim + + ALEOrganizeImports + + " We shouldn't register the callback yet. + AssertEqual '''''', string(g:Callback) + + AssertEqual type(function('type')), type(g:InitCallback) + call g:InitCallback() + + AssertEqual + \ 'function(''ale#organize_imports#HandleTSServerResponse'')', + \ string(g:Callback) + + AssertEqual + \ [ + \ ale#lsp#tsserver_message#Change(bufnr('')), + \ [0, 'ts@organizeImports', { + \ 'scope': { + \ 'type': 'file', + \ 'args': { + \ 'file': expand('%:p'), + \ }, + \ }, + \ }] + \ ], + \ g:message_list + +Given python(Some Python file): + foo + somelongerline + bazxyzxyzxyz + +Execute(Should result in error message): + call ale#linter#Reset() + runtime ale_linters/python/pyls.vim + let b:ale_linters = ['pyls'] + + ALEOrganizeImports + + " We shouldn't register the callback yet. + AssertEqual '''''', string(g:Callback) + + AssertEqual type(function('type')), type(g:InitCallback) + call g:InitCallback() + + AssertEqual [ + \ 'echom ''OrganizeImports currently only works with tsserver''', + \], g:expr_list diff --git a/test/test_rename.vader b/test/test_rename.vader new file mode 100644 index 00000000..98e3ef30 --- /dev/null +++ b/test/test_rename.vader @@ -0,0 +1,394 @@ +Before: + call ale#test#SetDirectory('/testplugin/test') + call ale#test#SetFilename('dummy.txt') + + 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/rename.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) abort + let g:handle_code_action_called = 1 + call add(g:code_actions, a:code_action) + endfunction + + function! ale#util#Input(message, value) abort + return 'a-new-name' + endfunction + + call ale#rename#SetMap({ + \ 3: { + \ 'old_name': 'oldName', + \ 'new_name': 'aNewName', + \ }, + \}) + +After: + if g:conn_id isnot v:null + call ale#lsp#RemoveConnectionWithID(g:conn_id) + endif + + call ale#rename#SetMap({}) + 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/rename.vim + runtime autoload/ale/code_action.vim + +Execute(Other messages for the tsserver handler should be ignored): + call ale#rename#HandleTSServerResponse(1, {'command': 'foo'}) + AssertEqual g:handle_code_action_called, 0 + +Execute(Failed rename responses should be handled correctly): + call ale#rename#SetMap({3: {'old_name': 'oldName', 'new_name': 'a-test'}}) + call ale#rename#HandleTSServerResponse( + \ 1, + \ {'command': 'rename', 'request_seq': 3} + \) + AssertEqual g:handle_code_action_called, 0 + +Given typescript(Some typescript file): + foo + somelongerline + bazxyzxyzxyz + +Execute(Code actions from tsserver should be handled): + call ale#rename#HandleTSServerResponse(1, { + \ 'command': 'rename', + \ 'request_seq': 3, + \ 'success': v:true, + \ 'body': { + \ 'locs': [ + \ { + \ 'file': '/foo/bar/file1.ts', + \ 'locs': [ + \ { + \ 'start': { + \ 'line': 1, + \ 'offset': 2, + \ }, + \ 'end': { + \ 'line': 3, + \ 'offset': 4, + \ }, + \ }, + \ ], + \ }, + \ { + \ 'file': '/foo/bar/file2.ts', + \ 'locs': [ + \ { + \ 'start': { + \ 'line': 10, + \ 'offset': 20, + \ }, + \ 'end': { + \ 'line': 30, + \ 'offset': 40, + \ }, + \ }, + \ ], + \ }, + \ ] + \ }, + \}) + + AssertEqual + \ [ + \ { + \ 'description': 'rename', + \ 'changes': [ + \ { + \ 'fileName': '/foo/bar/file1.ts', + \ 'textChanges': [ + \ { + \ 'start': { + \ 'line': 1, + \ 'offset': 2, + \ }, + \ 'end': { + \ 'line': 3, + \ 'offset': 4, + \ }, + \ 'newText': 'aNewName', + \ }, + \ ], + \ }, + \ { + \ 'fileName': '/foo/bar/file2.ts', + \ 'textChanges': [ + \ { + \ 'start': { + \ 'line': 10, + \ 'offset': 20, + \ }, + \ 'end': { + \ 'line': 30, + \ 'offset': 40, + \ }, + \ 'newText': 'aNewName', + \ }, + \ ], + \ }, + \ ], + \ } + \ ], + \ g:code_actions + +Execute(HandleTSServerResponse does nothing when no data in rename_map): + call ale#rename#HandleTSServerResponse(1, { + \ 'command': 'rename', + \ 'request_seq': -9, + \ 'success': v:true, + \ 'body': {} + \}) + + AssertEqual g:handle_code_action_called, 0 + +Execute(Prints a tsserver error message when unsuccessful): + call ale#rename#HandleTSServerResponse(1, { + \ 'command': 'rename', + \ 'request_seq': 3, + \ 'success': v:false, + \ 'message': 'This symbol cannot be renamed', + \}) + + AssertEqual g:handle_code_action_called, 0 + AssertEqual ['echom ''Error renaming "oldName" to: "aNewName". ' . + \ 'Reason: This symbol cannot be renamed'''], g:expr_list + +Execute(Does nothing when no changes): + call ale#rename#HandleTSServerResponse(1, { + \ 'command': 'rename', + \ 'request_seq': 3, + \ 'success': v:true, + \ 'body': { + \ 'locs': [] + \ } + \}) + + AssertEqual g:handle_code_action_called, 0 + AssertEqual ['echom ''Error renaming "oldName" to: "aNewName"'''], g:expr_list + +Execute(tsserver rename requests should be sent): + call ale#rename#SetMap({}) + call ale#linter#Reset() + + runtime ale_linters/typescript/tsserver.vim + call setpos('.', [bufnr(''), 2, 5, 0]) + + ALERename + + " We shouldn't register the callback yet. + AssertEqual '''''', string(g:Callback) + + AssertEqual type(function('type')), type(g:InitCallback) + call g:InitCallback() + + AssertEqual 'rename', g:capability_checked + AssertEqual + \ 'function(''ale#rename#HandleTSServerResponse'')', + \ string(g:Callback) + AssertEqual + \ [ + \ ale#lsp#tsserver_message#Change(bufnr('')), + \ [0, 'ts@rename', { + \ 'file': expand('%:p'), + \ 'line': 2, + \ 'offset': 5, + \ 'arguments': { + \ 'findInComments': g:ale_rename_tsserver_find_in_comments, + \ 'findInStrings': g:ale_rename_tsserver_find_in_strings, + \ }, + \ }] + \ ], + \ g:message_list + AssertEqual {'42': {'old_name': 'somelongerline', 'new_name': 'a-new-name'}}, + \ ale#rename#GetMap() + +Given python(Some Python file): + foo + somelongerline + bazxyzxyzxyz + +Execute(Code actions from LSP should be handled): + call ale#rename#HandleLSPResponse(1, { + \ 'id': 3, + \ 'result': { + \ 'changes': { + \ 'file:///foo/bar/file1.ts': [ + \ { + \ 'range': { + \ 'start': { + \ 'line': 1, + \ 'character': 2, + \ }, + \ 'end': { + \ 'line': 3, + \ 'character': 4, + \ }, + \ }, + \ 'newText': 'bla123' + \ }, + \ ], + \ }, + \ }, + \}) + + AssertEqual + \ [ + \ { + \ 'description': 'rename', + \ 'changes': [ + \ { + \ 'fileName': '/foo/bar/file1.ts', + \ 'textChanges': [ + \ { + \ 'start': { + \ 'line': 2, + \ 'offset': 3, + \ }, + \ 'end': { + \ 'line': 4, + \ 'offset': 5, + \ }, + \ 'newText': 'bla123', + \ }, + \ ], + \ }, + \ ], + \ } + \ ], + \ g:code_actions + +Execute(LSP should perform no action when no result): + call ale#rename#HandleLSPResponse(1, { + \ 'id': 3, + \}) + + AssertEqual g:handle_code_action_called, 0 + AssertEqual ['echom ''No rename result received from server'''], g:expr_list + +Execute(LSP should perform no action when no changes): + call ale#rename#HandleLSPResponse(1, { + \ 'id': 3, + \ 'result': {}, + \}) + + AssertEqual g:handle_code_action_called, 0 + AssertEqual ['echom ''No changes received from server'''], g:expr_list + +Execute(LSP should perform no action when changes is empty): + call ale#rename#HandleLSPResponse(1, { + \ 'id': 3, + \ 'result': { + \ 'changes': [], + \ }, + \}) + + AssertEqual g:handle_code_action_called, 0 + AssertEqual ['echom ''No changes received from server'''], g:expr_list + +Execute(LSP rename requests should be sent): + call ale#rename#SetMap({}) + runtime ale_linters/python/pyls.vim + let b:ale_linters = ['pyls'] + call setpos('.', [bufnr(''), 1, 5, 0]) + + ALERename + + " We shouldn't register the callback yet. + AssertEqual '''''', string(g:Callback) + + AssertEqual type(function('type')), type(g:InitCallback) + call g:InitCallback() + + AssertEqual 'rename', g:capability_checked + AssertEqual + \ 'function(''ale#rename#HandleLSPResponse'')', + \ string(g:Callback) + + AssertEqual + \ [ + \ [1, 'textDocument/didChange', { + \ 'textDocument': { + \ 'uri': ale#path#ToURI(expand('%:p')), + \ 'version': g:ale_lsp_next_version_id - 1, + \ }, + \ 'contentChanges': [{'text': join(getline(1, '$'), "\n") . "\n"}] + \ }], + \ [0, 'textDocument/rename', { + \ 'textDocument': {'uri': ale#path#ToURI(expand('%:p'))}, + \ 'position': {'line': 0, 'character': 2}, + \ 'newName': 'a-new-name', + \ }], + \ ], + \ g:message_list + + AssertEqual {'42': {'old_name': 'foo', 'new_name': 'a-new-name'}}, + \ ale#rename#GetMap()