diff --git a/autoload/ale/lsp.vim b/autoload/ale/lsp.vim index 8da95ef6..7186d2a9 100644 --- a/autoload/ale/lsp.vim +++ b/autoload/ale/lsp.vim @@ -57,6 +57,15 @@ function! ale#lsp#RemoveConnectionWithID(id) abort endif endfunction +function! ale#lsp#ResetConnections() abort + let s:connections = {} +endfunction + +" Used only in tests. +function! ale#lsp#GetConnections() abort + return s:connections +endfunction + " This is only needed for tests function! ale#lsp#MarkDocumentAsOpen(id, buffer) abort let l:conn = get(s:connections, a:id, {}) diff --git a/autoload/ale/lsp_linter.vim b/autoload/ale/lsp_linter.vim index d0f66bf7..d544916a 100644 --- a/autoload/ale/lsp_linter.vim +++ b/autoload/ale/lsp_linter.vim @@ -228,6 +228,103 @@ function! ale#lsp_linter#OnInit(linter, details, Callback) abort call a:Callback(a:linter, a:details) endfunction +function! s:StartLSP(options, address, executable, command) abort + let l:buffer = a:options.buffer + let l:linter = a:options.linter + let l:root = a:options.root + let l:Callback = a:options.callback + + let l:init_options = ale#lsp_linter#GetOptions(l:buffer, l:linter) + + if l:linter.lsp is# 'socket' + let l:conn_id = ale#lsp#Register(a:address, l:root, l:init_options) + let l:ready = ale#lsp#ConnectToAddress(l:conn_id, a:address) + let l:command = '' + else + let l:conn_id = ale#lsp#Register(a:executable, l:root, l:init_options) + + " tsserver behaves differently, so tell the LSP API that it is tsserver. + if l:linter.lsp is# 'tsserver' + call ale#lsp#MarkConnectionAsTsserver(l:conn_id) + endif + + let l:command = ale#command#FormatCommand(l:buffer, a:executable, a:command, 0, v:false)[1] + let l:command = ale#job#PrepareCommand(l:buffer, l:command) + let l:ready = ale#lsp#StartProgram(l:conn_id, a:executable, l:command) + endif + + if !l:ready + if g:ale_history_enabled && !empty(a:command) + call ale#history#Add(l:buffer, 'failed', l:conn_id, a:command) + endif + + return 0 + endif + + let l:details = { + \ 'buffer': l:buffer, + \ 'connection_id': l:conn_id, + \ 'command': l:command, + \ 'project_root': l:root, + \} + + call ale#lsp#OnInit(l:conn_id, {-> + \ ale#lsp_linter#OnInit(l:linter, l:details, l:Callback) + \}) + + return 1 +endfunction + +function! s:StartWithAddress(options, address) abort + if ale#command#IsDeferred(a:address) + let a:address.result_callback = { + \ address -> s:StartWithAddress(a:options, address) + \} + + return 1 + endif + + if empty(a:address) + return 0 + endif + + return s:StartLSP(a:options, a:address, '', '') +endfunction + +function! s:StartWithCommand(options, executable, command) abort + if ale#command#IsDeferred(a:command) + let a:command.result_callback = { + \ command -> s:StartWithCommand(a:options, a:executable, command) + \} + + return 1 + endif + + if empty(a:command) + return 0 + endif + + return s:StartLSP(a:options, '', a:executable, a:command) +endfunction + +function! s:StartIfExecutable(options, executable) abort + if ale#command#IsDeferred(a:executable) + let a:executable.result_callback = { + \ executable -> s:StartIfExecutable(a:options, executable) + \} + + return 1 + endif + + if !ale#engine#IsExecutable(a:options.buffer, a:executable) + return 0 + endif + + let l:command = ale#linter#GetCommand(a:options.buffer, a:options.linter) + + return s:StartWithCommand(a:options, a:executable, l:command) +endfunction + " Given a buffer, an LSP linter, start up an LSP linter and get ready to " receive messages for the document. function! ale#lsp_linter#StartLSP(buffer, linter, Callback) abort @@ -241,54 +338,22 @@ function! ale#lsp_linter#StartLSP(buffer, linter, Callback) abort return 0 endif - let l:init_options = ale#lsp_linter#GetOptions(a:buffer, a:linter) + let l:options = { + \ 'buffer': a:buffer, + \ 'linter': a:linter, + \ 'callback': a:Callback, + \ 'root': l:root, + \} if a:linter.lsp is# 'socket' let l:address = ale#linter#GetAddress(a:buffer, a:linter) - let l:conn_id = ale#lsp#Register(l:address, l:root, l:init_options) - let l:ready = ale#lsp#ConnectToAddress(l:conn_id, l:address) - else - let l:executable = ale#linter#GetExecutable(a:buffer, a:linter) - if !ale#engine#IsExecutable(a:buffer, l:executable) - return 0 - endif - - let l:conn_id = ale#lsp#Register(l:executable, l:root, l:init_options) - - " tsserver behaves differently, so tell the LSP API that it is tsserver. - if a:linter.lsp is# 'tsserver' - call ale#lsp#MarkConnectionAsTsserver(l:conn_id) - endif - - let l:command = ale#linter#GetCommand(a:buffer, a:linter) - " Format the command, so %e can be formatted into it. - let l:command = ale#command#FormatCommand(a:buffer, l:executable, l:command, 0, v:false)[1] - let l:command = ale#job#PrepareCommand(a:buffer, l:command) - let l:ready = ale#lsp#StartProgram(l:conn_id, l:executable, l:command) + return s:StartWithAddress(l:options, l:address) endif - if !l:ready - if g:ale_history_enabled && !empty(l:command) - call ale#history#Add(a:buffer, 'failed', l:conn_id, l:command) - endif + let l:executable = ale#linter#GetExecutable(a:buffer, a:linter) - return 0 - endif - - - let l:details = { - \ 'buffer': a:buffer, - \ 'connection_id': l:conn_id, - \ 'command': l:command, - \ 'project_root': l:root, - \} - - call ale#lsp#OnInit(l:conn_id, {-> - \ ale#lsp_linter#OnInit(a:linter, l:details, a:Callback) - \}) - - return 1 + return s:StartIfExecutable(l:options, l:executable) endfunction function! s:CheckWithLSP(linter, details) abort diff --git a/test/lsp/test_lsp_startup.vader b/test/lsp/test_lsp_startup.vader new file mode 100644 index 00000000..2fbd32de --- /dev/null +++ b/test/lsp/test_lsp_startup.vader @@ -0,0 +1,364 @@ +Before: + Save g:ale_run_synchronously + + let g:ale_run_synchronously = 1 + unlet! g:ale_run_synchronously_callbacks + + runtime autoload/ale/lsp.vim + runtime autoload/ale/lsp_linter.vim + runtime autoload/ale/engine.vim + runtime autoload/ale/job.vim + runtime autoload/ale/socket.vim + + let g:job_map = {} + let g:emulate_job_failure = 0 + let g:next_job_id = 1 + + let g:socket_map = {} + let g:emulate_socket_failure = 0 + let g:next_channel_id = 0 + + let g:message_buffer = '' + let g:calls = [] + + function! ale#engine#IsExecutable(buffer, executable) abort + return !empty(a:executable) + endfunction + + function! ale#job#HasOpenChannel(job_id) abort + return has_key(g:job_map, a:job_id) + endfunction + + function! ale#job#Stop(job_id) abort + if has_key(g:job_map, a:job_id) + call remove(g:job_map, a:job_id) + endif + endfunction + + function! ale#job#Start(command, options) abort + if g:emulate_job_failure + return 0 + endif + + let l:job_id = g:next_job_id + let g:next_job_id += 1 + let g:job_map[l:job_id] = [a:command, a:options] + + return l:job_id + endfunction + + function! ale#job#SendRaw(job_id, data) abort + let g:message_buffer .= a:data + endfunction + + function! ale#socket#IsOpen(channel_id) abort + return has_key(g:socket_map, a:channel_id) + endfunction + + function! ale#socket#Close(channel_id) abort + if has_key(g:socket_map, a:channel_id) + call remove(g:socket_map, a:channel_id) + endif + endfunction + + function! ale#socket#Open(address, options) abort + if g:emulate_socket_failure + return -1 + endif + + let l:channel_id = g:next_channel_id + let g:next_channel_id += 1 + let g:socket_map[l:channel_id] = [a:address, a:options] + + return l:channel_id + endfunction + + function! ale#socket#Send(channel_id, data) abort + let g:message_buffer .= a:data + endfunction + + function! PopMessages() abort + let l:message_list = [] + + for l:line in split(g:message_buffer, '\(\r\|\n\|Content-Length\)\+') + if l:line[:0] is '{' + let l:data = json_decode(l:line) + + call add(l:message_list, l:data) + endif + endfor + + let g:message_buffer = '' + + return l:message_list + endfunction + + function! SendMessage(message) abort + let l:conn_id = keys(ale#lsp#GetConnections())[0] + let l:body = json_encode(a:message) + let l:data = 'Content-Length: ' . strlen(l:body) . "\r\n\r\n" . l:body + + call ale#lsp#HandleMessage(l:conn_id, l:data) + endfunction + + function! Start() abort + let l:linter = values(ale#linter#GetLintersLoaded())[0][0] + + return ale#lsp_linter#StartLSP( + \ bufnr(''), + \ l:linter, + \ {linter, details -> add(g:calls, [linter.name, details])}, + \) + endfunction + + function! AssertInitSuccess(linter_name, conn_prefix, language, root, command) abort + let l:messages = PopMessages() + + if a:linter_name is# 'tsserver' + AssertEqual + \ [ + \ { + \ 'seq': v:null, + \ 'arguments': { + \ 'file': expand('%:p'), + \ }, + \ 'type': 'request', + \ 'command': 'open', + \ }, + \ ], + \ l:messages + else + AssertEqual + \ [ + \ { + \ 'method': 'initialize', + \ 'jsonrpc': '2.0', + \ 'id': 1, + \ 'params': { + \ 'initializationOptions': {}, + \ 'rootUri': ale#path#ToURI(a:root), + \ 'capabilities': {}, + \ 'rootPath': a:root, + \ 'processId': getpid(), + \ }, + \ }, + \ ], + \ l:messages + + call SendMessage({ + \ 'jsonrpc': '2.0', + \ 'id': 1, + \ 'result': { + \ 'capabilities': { + \ 'renameProvider': v:true, + \ 'executeCommandProvider': { + \ 'commands': [], + \ }, + \ 'hoverProvider': v:true, + \ 'documentSymbolProvider': v:true, + \ 'documentRangeFormattingProvider': v:true, + \ 'codeLensProvider': { + \ 'resolveProvider': v:false + \ }, + \ 'referencesProvider': v:true, + \ 'textDocumentSync': 2, + \ 'documentFormattingProvider': v:true, + \ 'codeActionProvider': v:true, + \ 'signatureHelpProvider': { + \ 'triggerCharacters': ['(', ','], + \ }, + \ 'completionProvider': { + \ 'triggerCharacters': ['.'], + \ 'resolveProvider': v:false + \ }, + \ 'definitionProvider': v:true, + \ 'experimental': {}, + \ 'documentHighlightProvider': v:true, + \ 'workspaceSymbolProvider': v:true, + \ }, + \ }, + \}) + + let l:messages = PopMessages() + + AssertEqual + \ [ + \ { + \ 'method': 'initialized', + \ 'jsonrpc': '2.0', + \ 'params': {}, + \ }, + \ { + \ 'method': 'textDocument/didOpen', + \ 'jsonrpc': '2.0', + \ 'params': { + \ 'textDocument': { + \ 'uri': ale#path#ToURI(expand('%:p')), + \ 'version': ale#lsp#message#GetNextVersionID() - 1, + \ 'languageId': a:language, + \ 'text': "\n", + \ }, + \ }, + \ }, + \ ], + \ l:messages + endif + + AssertEqual + \ [ + \ [ + \ a:linter_name, + \ { + \ 'connection_id': a:conn_prefix . ':' . a:root, + \ 'project_root': a:root, + \ 'buffer': bufnr(''), + \ 'command': !empty(a:command) ? ale#job#PrepareCommand(bufnr(''), a:command) : '', + \ }, + \ ], + \ ], + \ g:calls + endfunction + + function! AssertInitFailure() abort + let l:messages = PopMessages() + + AssertEqual [], l:messages + AssertEqual [], g:calls + endfunction + + call ale#linter#Reset() + +After: + Restore + + call ale#linter#Reset() + call ale#lsp#ResetConnections() + + unlet! g:job_map + unlet! g:emulate_job_failure + unlet! g:next_job_id + + unlet! g:socket_map + unlet! g:emulate_socket_failure + unlet! g:next_channel_id + + unlet! g:message_buffer + unlet! g:calls + + delfunction PopMessages + delfunction Start + delfunction AssertInitSuccess + delfunction AssertInitFailure + + runtime autoload/ale/engine.vim + runtime autoload/ale/job.vim + runtime autoload/ale/socket.vim + +Execute(tsserver should be started correctly): + runtime ale_linters/typescript/tsserver.vim + + Assert Start() + call AssertInitSuccess('tsserver', 'tsserver', '', '', ale#Escape('tsserver')) + +Execute(tsserver failures should be handled appropriately): + runtime ale_linters/typescript/tsserver.vim + + let g:emulate_job_failure = 1 + + Assert !Start() + call AssertInitFailure() + +Execute(LSP jobs should start correctly): + call ale#linter#Define('foobar', { + \ 'name': 'foo', + \ 'lsp': 'stdio', + \ 'executable': 'foo', + \ 'command': 'foo', + \ 'project_root': '/foo/bar', + \ 'initialization_options': {}, + \}) + + Assert Start() + call AssertInitSuccess('foo', 'foo', 'foobar', '/foo/bar', 'foo') + +Execute(LSP job failures should be handled): + call ale#linter#Define('foobar', { + \ 'name': 'foo', + \ 'lsp': 'stdio', + \ 'executable': 'foo', + \ 'command': 'foo', + \ 'project_root': '/foo/bar', + \ 'initialization_options': {}, + \}) + + let g:emulate_job_failure = 1 + + Assert !Start() + call AssertInitFailure() + +Execute(LSP TCP connections should start correctly): + call ale#linter#Define('foobar', { + \ 'name': 'foo', + \ 'lsp': 'socket', + \ 'address': 'foo', + \ 'project_root': '/foo/bar', + \ 'initialization_options': {}, + \}) + + Assert Start() + call AssertInitSuccess('foo', 'foo', 'foobar', '/foo/bar', '') + +Execute(LSP TCP connection failures should be handled): + call ale#linter#Define('foobar', { + \ 'name': 'foo', + \ 'lsp': 'socket', + \ 'address': 'foo', + \ 'project_root': '/foo/bar', + \ 'initialization_options': {}, + \}) + + let g:emulate_socket_failure = 1 + + Assert !Start() + call AssertInitFailure() + +Execute(Deferred executables should be handled correctly): + call ale#linter#Define('foobar', { + \ 'name': 'foo', + \ 'lsp': 'stdio', + \ 'executable': {b -> ale#command#Run(b, 'echo', {-> 'foo'})}, + \ 'command': '%e -c', + \ 'project_root': '/foo/bar', + \ 'initialization_options': {}, + \}) + + Assert Start() + call ale#test#FlushJobs() + call AssertInitSuccess('foo', 'foo', 'foobar', '/foo/bar', ale#Escape('foo') . ' -c') + +Execute(Deferred commands should be handled correctly): + call ale#linter#Define('foobar', { + \ 'name': 'foo', + \ 'lsp': 'stdio', + \ 'executable': 'foo', + \ 'command': {b -> ale#command#Run(b, 'echo', {-> '%e -c'})}, + \ 'project_root': '/foo/bar', + \ 'initialization_options': {}, + \}) + + Assert Start() + call ale#test#FlushJobs() + call AssertInitSuccess('foo', 'foo', 'foobar', '/foo/bar', ale#Escape('foo') . ' -c') + +Execute(Deferred addresses should be handled correctly): + call ale#linter#Define('foobar', { + \ 'name': 'foo', + \ 'lsp': 'socket', + \ 'address': {b -> ale#command#Run(b, 'echo', {-> 'localhost:1234'})}, + \ 'project_root': '/foo/bar', + \ 'initialization_options': {}, + \}) + + Assert Start() + call ale#test#FlushJobs() + call AssertInitSuccess('foo', 'localhost:1234', 'foobar', '/foo/bar', '')