summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJerko Steiner <jerko.steiner@gmail.com>2019-09-13 03:53:23 +0700
committerw0rp <w0rp@users.noreply.github.com>2019-09-12 21:53:23 +0100
commit3e8c8d3ccbead8a6540b782686ab3751361a4ec1 (patch)
tree464f0eb87327ec02dd7818074ac8eed5c71f83d5
parentb66d202c1dfc1354d09f8d5eacef54404e9b2c57 (diff)
downloadale-3e8c8d3ccbead8a6540b782686ab3751361a4ec1.zip
Add ALERename (tsserver & LSP), ALEOrganizeImports (tsserver) and auto import support (tsserver) (#2709)
This commit adds support for renaming symbols in tsserver and with LSP tools, and for organising imports with tsserver. Completion results for symbols that can be imported are now suggested if enabled for tsserver completion done via ALE.
-rw-r--r--README.md8
-rw-r--r--autoload/ale/code_action.vim163
-rw-r--r--autoload/ale/completion.vim59
-rw-r--r--autoload/ale/lsp.vim6
-rw-r--r--autoload/ale/lsp/message.vim10
-rw-r--r--autoload/ale/lsp/tsserver_message.vim28
-rw-r--r--autoload/ale/organize_imports.vim59
-rw-r--r--autoload/ale/rename.vim225
-rw-r--r--autoload/ale/util.vim3
-rw-r--r--doc/ale.txt47
-rw-r--r--plugin/ale.vim7
-rw-r--r--test/completion/test_completion_events.vader53
-rw-r--r--test/completion/test_lsp_completion_messages.vader33
-rw-r--r--test/completion/test_tsserver_completion_parsing.vader38
-rw-r--r--test/lsp/test_lsp_client_messages.vader3
-rw-r--r--test/lsp/test_other_initialize_message_handling.vader5
-rw-r--r--test/test_autocmd_commands.vader4
-rw-r--r--test/test_code_action.vader334
-rw-r--r--test/test_organize_imports.vader171
-rw-r--r--test/test_rename.vader394
20 files changed, 1633 insertions, 17 deletions
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 `<C-x><C-o>`.
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.
<a name="usage-go-to-definition"></a>
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 <jerko.steiner@gmail.com>
+" 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 <silent> <Plug>(ale_show_completion_menu) <Nop>
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 <jerko.steiner@gmail.com>
+" 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 <jerko.steiner@gmail.com>
+" 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('<cword>')
+ 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 `<Plug>(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 `<query>` *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(<q-args>)
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()
+
" <Plug> mappings for commands
nnoremap <silent> <Plug>(ale_previous) :ALEPrevious<Return>
nnoremap <silent> <Plug>(ale_previous_wrap) :ALEPreviousWrap<Return>
@@ -259,6 +265,7 @@ nnoremap <silent> <Plug>(ale_find_references) :ALEFindReferences<Return>
nnoremap <silent> <Plug>(ale_hover) :ALEHover<Return>
nnoremap <silent> <Plug>(ale_documentation) :ALEDocumentation<Return>
inoremap <silent> <Plug>(ale_complete) <C-\><C-O>:ALEComplete<Return>
+nnoremap <silent> <Plug>(ale_rename) :ALERename<Return>
" 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 <Plug> 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()