diff --git a/README.md b/README.md index 876ee4cd..066d6eb9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ formatting tools, and some Language Server Protocol and `tsserver` features. 2. [Fixing](#usage-fixing) 3. [Completion](#usage-completion) 4. [Go To Definition](#usage-go-to-definition) + 5. [Find References](#usage-find-references) 3. [Installation](#installation) 1. [Installation with Vim package management](#standard-installation) 2. [Installation with Pathogen](#installation-with-pathogen) @@ -240,6 +241,15 @@ ALE supports jumping to the definition of words under your cursor with the See `:help ale-go-to-definition` for more information. + + +### 2.v Find References + +ALE supports finding references for words under your cursor with the +`:ALEFindReferences` command using any enabled LSP linters and `tsserver`. + +See `:help ale-find-references` for more information. + ## 3. Installation diff --git a/autoload/ale/lsp/message.vim b/autoload/ale/lsp/message.vim index 0b73cfc2..037e6ce2 100644 --- a/autoload/ale/lsp/message.vim +++ b/autoload/ale/lsp/message.vim @@ -116,3 +116,13 @@ function! ale#lsp#message#Definition(buffer, line, column) abort \ 'position': {'line': a:line - 1, 'character': a:column}, \}] endfunction + +function! ale#lsp#message#References(buffer, line, column) abort + return [0, 'textDocument/references', { + \ 'textDocument': { + \ 'uri': ale#path#ToURI(expand('#' . a:buffer . ':p')), + \ }, + \ 'position': {'line': a:line - 1, 'character': a:column}, + \ 'context': {'includeDeclaration': v:false}, + \}] +endfunction diff --git a/autoload/ale/lsp/tsserver_message.vim b/autoload/ale/lsp/tsserver_message.vim index b9bd7a01..415fde95 100644 --- a/autoload/ale/lsp/tsserver_message.vim +++ b/autoload/ale/lsp/tsserver_message.vim @@ -61,3 +61,11 @@ function! ale#lsp#tsserver_message#Definition(buffer, line, column) abort \ 'file': expand('#' . a:buffer . ':p'), \}] endfunction + +function! ale#lsp#tsserver_message#References(buffer, line, column) abort + return [0, 'ts@references', { + \ 'line': a:line, + \ 'offset': a:column, + \ 'file': expand('#' . a:buffer . ':p'), + \}] +endfunction diff --git a/autoload/ale/references.vim b/autoload/ale/references.vim new file mode 100644 index 00000000..9777519d --- /dev/null +++ b/autoload/ale/references.vim @@ -0,0 +1,111 @@ +let s:references_map = {} + +" Used to get the references map in tests. +function! ale#references#GetMap() abort + return deepcopy(s:references_map) +endfunction + +" Used to set the references map in tests. +function! ale#references#SetMap(map) abort + let s:references_map = a:map +endfunction + +function! ale#references#ClearLSPData() abort + let s:references_map = {} +endfunction + +function! ale#references#HandleTSServerResponse(conn_id, response) abort + if get(a:response, 'command', '') is# 'references' + \&& has_key(s:references_map, a:response.request_seq) + call remove(s:references_map, a:response.request_seq) + + if get(a:response, 'success', v:false) is v:true + let l:item_list = [] + + for l:response_item in a:response.body.refs + call add(l:item_list, { + \ 'filename': l:response_item.file, + \ 'line': l:response_item.start.line, + \ 'column': l:response_item.start.offset, + \}) + endfor + + if empty(l:item_list) + call ale#util#Execute('echom ''No references found.''') + else + call ale#preview#ShowSelection(l:item_list) + endif + endif + endif +endfunction + +function! ale#references#HandleLSPResponse(conn_id, response) abort + if has_key(a:response, 'id') + \&& has_key(s:references_map, a:response.id) + call remove(s:references_map, a:response.id) + + " The result can be a Dictionary item, a List of the same, or null. + let l:result = get(a:response, 'result', []) + let l:item_list = [] + + for l:response_item in l:result + call add(l:item_list, { + \ 'filename': ale#path#FromURI(l:response_item.uri), + \ 'line': l:response_item.range.start.line + 1, + \ 'column': l:response_item.range.start.character + 1, + \}) + endfor + + if empty(l:item_list) + call ale#util#Execute('echom ''No references found.''') + else + call ale#preview#ShowSelection(l:item_list) + endif + endif +endfunction + +function! s:FindReferences(linter) abort + let l:buffer = bufnr('') + let [l:line, l:column] = getcurpos()[1:2] + + let l:Callback = a:linter.lsp is# 'tsserver' + \ ? function('ale#references#HandleTSServerResponse') + \ : function('ale#references#HandleLSPResponse') + + let l:lsp_details = ale#linter#StartLSP(l:buffer, a:linter, l:Callback) + + if empty(l:lsp_details) + return 0 + endif + + let l:id = l:lsp_details.connection_id + let l:root = l:lsp_details.project_root + + if a:linter.lsp is# 'tsserver' + let l:message = ale#lsp#tsserver_message#References( + \ l:buffer, + \ l:line, + \ l:column + \) + else + " Send a message saying the buffer has changed first, or the + " references position probably won't make sense. + call ale#lsp#Send(l:id, ale#lsp#message#DidChange(l:buffer), l:root) + + let l:column = min([l:column, len(getline(l:line))]) + + let l:message = ale#lsp#message#References(l:buffer, l:line, l:column) + endif + + let l:request_id = ale#lsp#Send(l:id, l:message, l:root) + + let s:references_map[l:request_id] = {} +endfunction + +function! ale#references#Find() abort + for l:linter in ale#linter#Get(&filetype) + if !empty(l:linter.lsp) + call s:FindReferences(l:linter) + endif + endfor +endfunction diff --git a/doc/ale.txt b/doc/ale.txt index 1f988e7d..fa25cdde 100644 --- a/doc/ale.txt +++ b/doc/ale.txt @@ -13,6 +13,7 @@ CONTENTS *ale-contents* 5. Language Server Protocol Support.....|ale-lsp| 5.1 Completion........................|ale-completion| 5.2 Go To Definition..................|ale-go-to-definition| + 5.3 Find References...................|ale-find-references| 6. Global Options.......................|ale-options| 6.1 Highlights........................|ale-highlights| 6.2 Options for write-good Linter.....|ale-write-good-options| @@ -642,6 +643,17 @@ information returned by LSP servers. The following commands are supported: |ALEGoToDefinitionInTab| - The same, but for opening the file in a new tab. +------------------------------------------------------------------------------- +5.3 Find References *ale-find-references* + +ALE supports finding references for symbols though any enabled LSP linters. +ALE will display a preview window showing the places where a symbol is +referenced in a codebase when a command is run. The following commands are +supporte3: + +|ALEFindReferences| - Find references for the word under the cursor. + + =============================================================================== 6. Global Options *ale-options* @@ -1746,6 +1758,18 @@ ALE will use to search for Python executables. =============================================================================== 8. Commands/Keybinds *ale-commands* +ALEFindReferences *ALEFindReferences* + + Find references in the codebase for the symbol under the cursor using the + enabled LSP linters for the buffer. ALE will display a preview window + containing the results if some references are found. + + The window can be navigated using the usual Vim navigation commands. The + Enter key (``) can be used to jump to a referencing location, or the `t` + key can be used to jump to the location in a new tab. + + A plug mapping `(ale_find_references)` is defined for this command. + ALEFix *ALEFix* Fix problems with the current buffer. See |ale-fix| for more information. diff --git a/plugin/ale.vim b/plugin/ale.vim index 3f157bc0..f51e175b 100644 --- a/plugin/ale.vim +++ b/plugin/ale.vim @@ -271,6 +271,9 @@ command! -bar ALEFixSuggest :call ale#fix#registry#Suggest(&filetype) command! -bar ALEGoToDefinition :call ale#definition#GoTo({}) command! -bar ALEGoToDefinitionInTab :call ale#definition#GoTo({'open_in_tab': 1}) +" Find references for tsserver and LSP +command! -bar ALEFindReferences :call ale#references#Find() + " mappings for commands nnoremap (ale_previous) :ALEPrevious nnoremap (ale_previous_wrap) :ALEPreviousWrap @@ -291,6 +294,7 @@ nnoremap (ale_detail) :ALEDetail nnoremap (ale_fix) :ALEFix nnoremap (ale_go_to_definition) :ALEGoToDefinition nnoremap (ale_go_to_definition_in_tab) :ALEGoToDefinitionInTab +nnoremap (ale_find_references) :ALEFindReferences " Set up autocmd groups now. call ale#toggle#InitAuGroups() diff --git a/test/lsp/test_lsp_client_messages.vader b/test/lsp/test_lsp_client_messages.vader index 053da803..b0f0ed2b 100644 --- a/test/lsp/test_lsp_client_messages.vader +++ b/test/lsp/test_lsp_client_messages.vader @@ -144,6 +144,21 @@ Execute(ale#lsp#message#Definition() should return correct messages): \ ], \ ale#lsp#message#Definition(bufnr(''), 12, 34) +Execute(ale#lsp#message#References() should return correct messages): + AssertEqual + \ [ + \ 0, + \ 'textDocument/references', + \ { + \ 'textDocument': { + \ 'uri': ale#path#ToURI(g:dir . '/foo/bar.ts'), + \ }, + \ 'position': {'line': 11, 'character': 34}, + \ 'context': {'includeDeclaration': v:false}, + \ } + \ ], + \ ale#lsp#message#References(bufnr(''), 12, 34) + Execute(ale#lsp#tsserver_message#Open() should return correct messages): AssertEqual \ [ @@ -233,3 +248,16 @@ Execute(ale#lsp#tsserver_message#Definition() should return correct messages): \ } \ ], \ ale#lsp#tsserver_message#Definition(bufnr(''), 347, 12) + +Execute(ale#lsp#tsserver_message#References() should return correct messages): + AssertEqual + \ [ + \ 0, + \ 'ts@references', + \ { + \ 'file': ale#path#Simplify(g:dir . '/foo/bar.ts'), + \ 'line': 347, + \ 'offset': 12, + \ } + \ ], + \ ale#lsp#tsserver_message#References(bufnr(''), 347, 12) diff --git a/test/test_find_references.vader b/test/test_find_references.vader new file mode 100644 index 00000000..6ab8e8fb --- /dev/null +++ b/test/test_find_references.vader @@ -0,0 +1,243 @@ +Before: + call ale#test#SetDirectory('/testplugin/test') + call ale#test#SetFilename('dummy.txt') + + let g:old_filename = expand('%:p') + let g:Callback = 0 + let g:expr_list = [] + let g:message_list = [] + let g:preview_called = 0 + let g:item_list = [] + + runtime autoload/ale/linter.vim + runtime autoload/ale/lsp.vim + runtime autoload/ale/util.vim + runtime autoload/ale/preview.vim + + function! ale#linter#StartLSP(buffer, linter, callback) abort + let g:Callback = a:callback + + return { + \ 'connection_id': 347, + \ 'project_root': '/foo/bar', + \} + endfunction + + function! ale#lsp#Send(conn_id, message, root) 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#preview#ShowSelection(item_list) abort + let g:preview_called = 1 + let g:item_list = a:item_list + endfunction + +After: + call ale#references#SetMap({}) + call ale#test#RestoreDirectory() + call ale#linter#Reset() + + unlet! g:old_filename + unlet! g:Callback + unlet! g:message_list + unlet! g:expr_list + unlet! b:ale_linters + unlet! g:item_list + unlet! g:preview_called + + runtime autoload/ale/linter.vim + runtime autoload/ale/lsp.vim + runtime autoload/ale/util.vim + runtime autoload/ale/preview.vim + +Execute(Other messages for the tsserver handler should be ignored): + call ale#references#HandleTSServerResponse(1, {'command': 'foo'}) + +Execute(Failed reference responses should be handled correctly): + call ale#references#SetMap({3: {}}) + call ale#references#HandleTSServerResponse( + \ 1, + \ {'command': 'references', 'request_seq': 3} + \) + AssertEqual {}, ale#references#GetMap() + +Given typescript(Some typescript file): + foo + somelongerline + bazxyzxyzxyz + +Execute(Results should be shown for tsserver responses): + call ale#references#SetMap({3: {}}) + call ale#references#HandleTSServerResponse(1, { + \ 'command': 'references', + \ 'request_seq': 3, + \ 'success': v:true, + \ 'body': { + \ 'symbolStartOffset': 9, + \ 'refs': [ + \ { + \ 'file': '/foo/bar/app.ts', + \ 'isWriteAccess': v:true, + \ 'lineText': 'import {doSomething} from ''./whatever''', + \ 'end': {'offset': 24, 'line': 9}, + \ 'start': {'offset': 9, 'line': 9}, + \ 'isDefinition': v:true, + \ }, + \ { + \ 'file': '/foo/bar/app.ts', + \ 'isWriteAccess': v:false, + \ 'lineText': ' doSomething()', + \ 'end': {'offset': 18, 'line': 804}, + \ 'start': {'offset': 3, 'line': 804}, + \ 'isDefinition': v:false, + \ }, + \ { + \ 'file': '/foo/bar/other/app.ts', + \ 'isWriteAccess': v:false, + \ 'lineText': ' doSomething()', + \ 'end': {'offset': 18, 'line': 51}, + \ 'start': {'offset': 3, 'line': 51}, + \ 'isDefinition': v:false, + \ }, + \ ], + \ 'symbolDisplayString': 'import doSomething', + \ 'symbolName': 'doSomething()', + \ }, + \}) + + AssertEqual + \ [ + \ {'filename': '/foo/bar/app.ts', 'column': 9, 'line': 9}, + \ {'filename': '/foo/bar/app.ts', 'column': 3, 'line': 804}, + \ {'filename': '/foo/bar/other/app.ts', 'column': 3, 'line': 51}, + \ ], + \ g:item_list + AssertEqual {}, ale#references#GetMap() + +Execute(The preview window should not be opened for empty tsserver responses): + call ale#references#SetMap({3: {}}) + call ale#references#HandleTSServerResponse(1, { + \ 'command': 'references', + \ 'request_seq': 3, + \ 'success': v:true, + \ 'body': { + \ 'symbolStartOffset': 9, + \ 'refs': [ + \ ], + \ 'symbolDisplayString': 'import doSomething', + \ 'symbolName': 'doSomething()', + \ }, + \}) + + Assert !g:preview_called + AssertEqual {}, ale#references#GetMap() + AssertEqual ['echom ''No references found.'''], g:expr_list + +Execute(tsserver reference requests should be sent): + runtime ale_linters/typescript/tsserver.vim + call setpos('.', [bufnr(''), 2, 5, 0]) + + ALEFindReferences + + AssertEqual + \ 'function(''ale#references#HandleTSServerResponse'')', + \ string(g:Callback) + AssertEqual + \ [[0, 'ts@references', {'file': expand('%:p'), 'line': 2, 'offset': 5}]], + \ g:message_list + AssertEqual {'42': {}}, ale#references#GetMap() + +Given python(Some Python file): + foo + somelongerline + bazxyzxyzxyz + +Execute(LSP reference responses should be handled): + call ale#references#SetMap({3: {}}) + call ale#references#HandleLSPResponse( + \ 1, + \ { + \ 'id': 3, + \ 'result': [ + \ { + \ 'uri': ale#path#ToURI(ale#path#Simplify(g:dir . '/completion_dummy_file')), + \ 'range': { + \ 'start': {'line': 2, 'character': 7}, + \ }, + \ }, + \ { + \ 'uri': ale#path#ToURI(ale#path#Simplify(g:dir . '/other_file')), + \ 'range': { + \ 'start': {'line': 7, 'character': 15}, + \ }, + \ }, + \ ], + \ } + \) + + AssertEqual + \ [ + \ { + \ 'filename': ale#path#Simplify(g:dir . '/completion_dummy_file'), + \ 'line': 3, + \ 'column': 8, + \ }, + \ { + \ 'filename': ale#path#Simplify(g:dir . '/other_file'), + \ 'line': 8, + \ 'column': 16, + \ }, + \ ], + \ g:item_list + AssertEqual {}, ale#references#GetMap() + +Execute(Preview windows should not be opened for empty LSP reference responses): + call ale#references#SetMap({3: {}}) + call ale#references#HandleLSPResponse( + \ 1, + \ { + \ 'id': 3, + \ 'result': [ + \ ], + \ } + \) + + Assert !g:preview_called + AssertEqual {}, ale#references#GetMap() + AssertEqual ['echom ''No references found.'''], g:expr_list + +Execute(LSP reference requests should be sent): + runtime ale_linters/python/pyls.vim + let b:ale_linters = ['pyls'] + call setpos('.', [bufnr(''), 1, 5, 0]) + + ALEFindReferences + + AssertEqual + \ 'function(''ale#references#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/references', { + \ 'textDocument': {'uri': ale#path#ToURI(expand('%:p'))}, + \ 'position': {'line': 0, 'character': 3}, + \ 'context': {'includeDeclaration': v:false}, + \ }], + \ ], + \ g:message_list + + AssertEqual {'42': {}}, ale#references#GetMap()