From cd860e3e8d2b8d35920f27279bbd1ec346ac4d62 Mon Sep 17 00:00:00 2001 From: w0rp Date: Wed, 26 Jul 2017 10:37:37 +0100 Subject: [PATCH] #517 Add more code LSP support which makes the tssserver linter behave more like the LSP linters --- ale_linters/php/langserver.vim | 38 ++++++ ale_linters/typescript/tsserver.vim | 11 ++ autoload/ale/completion.vim | 44 +++---- autoload/ale/engine.vim | 96 +++++++-------- autoload/ale/job.vim | 4 + autoload/ale/linter.vim | 84 +++++++++++++ autoload/ale/lsp.vim | 127 +++++++++++++++++--- autoload/ale/lsp/message.vim | 52 +++++--- autoload/ale/lsp/response.vim | 7 +- autoload/ale/path.vim | 23 ++++ autoload/ale/uri.vim | 18 +++ test/lsp/test_lsp_client_messages.vader | 79 ++++++------ test/lsp/test_read_lsp_diagnostics.vader | 40 +++--- test/test_linter_defintion_processing.vader | 6 + test/test_path_uri.vader | 16 +++ test/util/test_cd_string_commands.vader | 9 +- 16 files changed, 485 insertions(+), 169 deletions(-) create mode 100644 ale_linters/php/langserver.vim create mode 100644 autoload/ale/uri.vim create mode 100644 test/test_path_uri.vader diff --git a/ale_linters/php/langserver.vim b/ale_linters/php/langserver.vim new file mode 100644 index 0000000..8dad5ac --- /dev/null +++ b/ale_linters/php/langserver.vim @@ -0,0 +1,38 @@ +" Author: Eric Stern +" Description: PHP Language server integration for ALE + +" This linter is disabled for now. +finish + +call ale#Set('php_langserver_executable', 'php-language-server.php') +call ale#Set('php_langserver_config_path', '') +call ale#Set('php_langserver_use_global', 0) + +function! ale_linters#php#langserver#GetExecutable(buffer) abort + return ale#node#FindExecutable(a:buffer, 'php_langserver', [ + \ 'vendor/bin/php-language-server.php', + \]) +endfunction + +function! ale_linters#php#langserver#GetCommand(buffer) abort + return 'php ' . ale_linters#php#langserver#GetExecutable(a:buffer) +endfunction + +function! ale_linters#php#langserver#GetLanguage(buffer) abort + return 'php' +endfunction + +function! ale_linters#php#langserver#GetProjectRoot(buffer) abort + let l:git_path = ale#path#FindNearestDirectory(a:buffer, '.git') + + return !empty(l:git_path) ? fnamemodify(l:git_path, ':h') : '' +endfunction + +call ale#linter#Define('php', { +\ 'name': 'langserver', +\ 'lsp': 'stdio', +\ 'executable_callback': 'ale_linters#php#langserver#GetExecutable', +\ 'command_callback': 'ale_linters#php#langserver#GetCommand', +\ 'language_callback': 'ale_linters#php#langserver#GetLanguage', +\ 'project_root_callback': 'ale_linters#php#langserver#GetProjectRoot', +\}) diff --git a/ale_linters/typescript/tsserver.vim b/ale_linters/typescript/tsserver.vim index 465e80c..7a155bd 100644 --- a/ale_linters/typescript/tsserver.vim +++ b/ale_linters/typescript/tsserver.vim @@ -5,6 +5,15 @@ call ale#Set('typescript_tsserver_executable', 'tsserver') call ale#Set('typescript_tsserver_config_path', '') call ale#Set('typescript_tsserver_use_global', 0) +" These functions need to be defined just to comply with the API for LSP. +function! ale_linters#typescript#tsserver#GetProjectRoot(buffer) abort + return '' +endfunction + +function! ale_linters#typescript#tsserver#GetLanguage(buffer) abort + return '' +endfunction + function! ale_linters#typescript#tsserver#GetExecutable(buffer) abort return ale#node#FindExecutable(a:buffer, 'typescript_tsserver', [ \ 'node_modules/.bin/tsserver', @@ -16,4 +25,6 @@ call ale#linter#Define('typescript', { \ 'lsp': 'tsserver', \ 'executable_callback': 'ale_linters#typescript#tsserver#GetExecutable', \ 'command_callback': 'ale_linters#typescript#tsserver#GetExecutable', +\ 'project_root_callback': 'ale_linters#typescript#tsserver#GetProjectRoot', +\ 'language_callback': 'ale_linters#typescript#tsserver#GetLanguage', \}) diff --git a/autoload/ale/completion.vim b/autoload/ale/completion.vim index 334e257..214891f 100644 --- a/autoload/ale/completion.vim +++ b/autoload/ale/completion.vim @@ -203,41 +203,29 @@ function! s:HandleTSServerLSPResponse(response) abort endif endfunction -function! s:GetCompletionsForTSServer(linter) abort +function! s:GetLSPCompletions(linter) abort let l:buffer = bufnr('') - let l:executable = ale#linter#GetExecutable(l:buffer, a:linter) - let l:command = ale#job#PrepareCommand( - \ ale#linter#GetCommand(l:buffer, a:linter), - \) - let l:id = ale#lsp#StartProgram( - \ l:executable, - \ l:command, + let l:lsp_details = ale#linter#StartLSP( + \ l:buffer, + \ a:linter, \ function('s:HandleTSServerLSPResponse'), \) - if !l:id - if g:ale_history_enabled - call ale#history#Add(l:buffer, 'failed', l:id, l:command) - endif + if empty(l:lsp_details) + return 0 endif - if ale#lsp#OpenTSServerDocumentIfNeeded(l:id, l:buffer) - if g:ale_history_enabled - call ale#history#Add(l:buffer, 'started', l:id, l:command) - endif - endif + let l:id = l:lsp_details.connection_id + let l:command = l:lsp_details.command + let l:root = l:lsp_details.project_root - call ale#lsp#Send(l:id, ale#lsp#tsserver_message#Change(l:buffer)) - - let l:request_id = ale#lsp#Send( - \ l:id, - \ ale#lsp#tsserver_message#Completions( - \ l:buffer, - \ b:ale_completion_info.line, - \ b:ale_completion_info.column, - \ b:ale_completion_info.prefix, - \ ), + let l:message = ale#lsp#tsserver_message#Completions( + \ l:buffer, + \ b:ale_completion_info.line, + \ b:ale_completion_info.column, + \ b:ale_completion_info.prefix, \) + let l:request_id = ale#lsp#Send(l:id, l:message, l:root) if l:request_id let b:ale_completion_info.conn_id = l:id @@ -268,7 +256,7 @@ function! ale#completion#GetCompletions() abort for l:linter in ale#linter#Get(&filetype) if l:linter.lsp ==# 'tsserver' - call s:GetCompletionsForTSServer(l:linter) + call s:GetLSPCompletions(l:linter) endif endfor endfunction diff --git a/autoload/ale/engine.vim b/autoload/ale/engine.vim index 60cdf48..1ffdf44 100644 --- a/autoload/ale/engine.vim +++ b/autoload/ale/engine.vim @@ -31,13 +31,15 @@ endfunction function! ale#engine#InitBufferInfo(buffer) abort if !has_key(g:ale_buffer_info, a:buffer) - " job_list will hold the list of jobs + " job_list will hold the list of job IDs + " active_linter_list will hold the list of active linter names " loclist holds the loclist items after all jobs have completed. " temporary_file_list holds temporary files to be cleaned up " temporary_directory_list holds temporary directories to be cleaned up " history holds a list of previously run commands for this buffer let g:ale_buffer_info[a:buffer] = { \ 'job_list': [], + \ 'active_linter_list': [], \ 'loclist': [], \ 'temporary_file_list': [], \ 'temporary_directory_list': [], @@ -114,6 +116,16 @@ function! s:GatherOutput(job_id, line) abort endfunction function! s:HandleLoclist(linter_name, buffer, loclist) abort + let l:buffer_info = get(g:ale_buffer_info, a:buffer, {}) + + if empty(l:buffer_info) + return + endif + + " Remove this linter from the list of active linters. + " This may have already been done when the job exits. + call filter(l:buffer_info.active_linter_list, 'v:val !=# a:linter_name') + " Make some adjustments to the loclists to fix common problems, and also " to set default values for loclist items. let l:linter_loclist = ale#engine#FixLocList(a:buffer, a:linter_name, a:loclist) @@ -154,6 +166,7 @@ function! s:HandleExit(job_id, exit_code) abort call ale#job#Stop(a:job_id) call remove(s:job_info_map, a:job_id) call filter(g:ale_buffer_info[l:buffer].job_list, 'v:val !=# a:job_id') + call filter(g:ale_buffer_info[l:buffer].active_linter_list, 'v:val !=# l:linter.name') " Stop here if we land in the handle for a job completing if we're in " a sandbox. @@ -180,29 +193,32 @@ function! s:HandleExit(job_id, exit_code) abort call s:HandleLoclist(l:linter.name, l:buffer, l:loclist) endfunction -function! s:HandleLSPResponse(response) abort - let l:is_diag_response = get(a:response, 'type', '') ==# 'event' - \ && get(a:response, 'event', '') ==# 'semanticDiag' +function! s:HandleLSPDiagnostics(response) abort + let l:filename = ale#path#FromURI(a:response.params.uri) + let l:buffer = bufnr(l:filename) + let l:loclist = ale#lsp#response#ReadDiagnostics(a:response) - if !l:is_diag_response - return - endif + call s:HandleLoclist('langserver', l:buffer, l:loclist) +endfunction +function! s:HandleTSServerDiagnostics(response) abort let l:buffer = bufnr(a:response.body.file) - - let l:info = get(g:ale_buffer_info, l:buffer, {}) - - if empty(l:info) - return - endif - - let l:info.waiting_for_tsserver = 0 - let l:loclist = ale#lsp#response#ReadTSServerDiagnostics(a:response) call s:HandleLoclist('tsserver', l:buffer, l:loclist) endfunction +function! s:HandleLSPResponse(response) abort + let l:method = get(a:response, 'method', '') + + if l:method ==# 'textDocument/publishDiagnostics' + call s:HandleLSPDiagnostics(a:response) + elseif get(a:response, 'type', '') ==# 'event' + \&& get(a:response, 'event', '') ==# 'semanticDiag' + call s:HandleTSServerDiagnostics(a:response) + endif +endfunction + function! ale#engine#SetResults(buffer, loclist) abort let l:linting_is_done = !ale#engine#IsCheckingBuffer(a:buffer) @@ -430,6 +446,7 @@ function! s:RunJob(options) abort if l:job_id " Add the job to the list of jobs, so we can track them. call add(g:ale_buffer_info[l:buffer].job_list, l:job_id) + call add(g:ale_buffer_info[l:buffer].active_linter_list, l:linter.name) let l:status = 'started' " Store the ID for the job in the map to read back again. @@ -555,41 +572,27 @@ function! s:StopCurrentJobs(buffer, include_lint_file_jobs) abort let l:info.job_list = l:new_job_list endfunction -function! s:CheckWithTSServer(buffer, linter, executable) abort - let l:info = g:ale_buffer_info[a:buffer] - - let l:command = ale#job#PrepareCommand( - \ ale#linter#GetCommand(a:buffer, a:linter), - \) - let l:id = ale#lsp#StartProgram( - \ a:executable, - \ l:command, +function! s:CheckWithLSP(buffer, linter) abort + let l:lsp_details = ale#linter#StartLSP( + \ a:buffer, + \ a:linter, \ function('s:HandleLSPResponse'), \) - if !l:id - if g:ale_history_enabled - call ale#history#Add(a:buffer, 'failed', l:id, l:command) - endif - + if empty(l:lsp_details) return 0 endif - if ale#lsp#OpenTSServerDocumentIfNeeded(l:id, a:buffer) - if g:ale_history_enabled - call ale#history#Add(a:buffer, 'started', l:id, l:command) - endif - endif + let l:id = l:lsp_details.connection_id + let l:root = l:lsp_details.project_root - call ale#lsp#Send(l:id, ale#lsp#tsserver_message#Change(a:buffer)) - - let l:request_id = ale#lsp#Send( - \ l:id, - \ ale#lsp#tsserver_message#Geterr(a:buffer), - \) + let l:change_message = a:linter.lsp ==# 'tsserver' + \ ? ale#lsp#tsserver_message#Geterr(a:buffer) + \ : ale#lsp#message#DidChange(a:buffer) + let l:request_id = ale#lsp#Send(l:id, l:change_message, l:root) if l:request_id != 0 - let l:info.waiting_for_tsserver = 1 + call add(g:ale_buffer_info[a:buffer].active_linter_list, a:linter.name) endif return l:request_id != 0 @@ -614,15 +617,12 @@ endfunction " " Returns 1 if the linter was successfully run. function! s:RunLinter(buffer, linter) abort - if empty(a:linter.lsp) || a:linter.lsp ==# 'tsserver' + if !empty(a:linter.lsp) || a:linter.lsp ==# 'tsserver' + return s:CheckWithLSP(a:buffer, a:linter) + else let l:executable = ale#linter#GetExecutable(a:buffer, a:linter) - " Run this program if it can be executed. if s:IsExecutable(l:executable) - if a:linter.lsp ==# 'tsserver' - return s:CheckWithTSServer(a:buffer, a:linter, l:executable) - endif - return s:InvokeChain(a:buffer, a:linter, 0, []) endif endif diff --git a/autoload/ale/job.vim b/autoload/ale/job.vim index 93f2882..63e42f7 100644 --- a/autoload/ale/job.vim +++ b/autoload/ale/job.vim @@ -199,6 +199,10 @@ function! ale#job#Start(command, options) abort let l:job_info = copy(a:options) let l:job_options = {} + if exists('*ch_logfile') + call ch_logfile(expand('~/channel.log'), 'a') + endif + if has('nvim') if has_key(a:options, 'out_cb') let l:job_options.on_stdout = function('s:NeoVimCallback') diff --git a/autoload/ale/linter.vim b/autoload/ale/linter.vim index 1c99a0c..0af42af 100644 --- a/autoload/ale/linter.vim +++ b/autoload/ale/linter.vim @@ -62,6 +62,7 @@ function! ale#linter#PreProcess(linter) abort let l:needs_address = l:obj.lsp ==# 'socket' let l:needs_executable = l:obj.lsp !=# 'socket' let l:needs_command = l:obj.lsp !=# 'socket' + let l:needs_lsp_details = !empty(l:obj.lsp) if empty(l:obj.lsp) let l:obj.callback = get(a:linter, 'callback') @@ -176,6 +177,20 @@ function! ale#linter#PreProcess(linter) abort throw '`address_callback` must be defined for getting the LSP address' endif + if l:needs_lsp_details + let l:obj.language_callback = get(a:linter, 'language_callback') + + if !s:IsCallback(l:obj.language_callback) + throw '`language_callback` must be a callback for LSP linters' + endif + + let l:obj.project_root_callback = get(a:linter, 'project_root_callback') + + if !s:IsCallback(l:obj.project_root_callback) + throw '`project_root_callback` must be a callback for LSP linters' + endif + endif + let l:obj.output_stream = get(a:linter, 'output_stream', 'stdout') if type(l:obj.output_stream) != type('') @@ -346,3 +361,72 @@ function! ale#linter#GetCommand(buffer, linter) abort \ ? ale#util#GetFunction(a:linter.command_callback)(a:buffer) \ : a:linter.command endfunction + +" Given a buffer and linter, get the address for connecting to the server. +function! ale#linter#GetAddress(buffer, linter) abort + return has_key(a:linter, 'address_callback') + \ ? ale#util#GetFunction(a:linter.address_callback)(a:buffer) + \ : a:linter.address +endfunction + +" Given a buffer, an LSP linter, and a callback to register for handling +" messages, start up an LSP linter and get ready to receive errors or +" completions. +function! ale#linter#StartLSP(buffer, linter, callback) abort + let l:command = '' + let l:address = '' + let l:root = ale#util#GetFunction(a:linter.project_root_callback)(a:buffer) + + if a:linter.lsp ==# 'socket' + let l:address = ale#linter#GetAddress(a:buffer, a:linter) + let l:conn_id = ale#lsp#ConnectToAddress( + \ l:address, + \ l:root, + \ a:callback, + \) + else + let l:executable = ale#linter#GetExecutable(a:buffer, a:linter) + + if !executable(l:executable) + return {} + endif + + let l:command = ale#job#PrepareCommand( + \ ale#linter#GetCommand(a:buffer, a:linter), + \) + let l:conn_id = ale#lsp#StartProgram( + \ l:executable, + \ l:command, + \ l:root, + \ a:callback, + \) + endif + + let l:language_id = ale#util#GetFunction(a:linter.language_callback)(a:buffer) + + if !l:conn_id + if g:ale_history_enabled && !empty(l:command) + call ale#history#Add(a:buffer, 'failed', l:conn_id, l:command) + endif + + return {} + endif + + if ale#lsp#OpenDocumentIfNeeded(l:conn_id, a:buffer, l:root, l:language_id) + if g:ale_history_enabled && !empty(l:command) + call ale#history#Add(a:buffer, 'started', l:conn_id, l:command) + endif + endif + + " The change message needs to be sent for tsserver before doing anything. + if a:linter.lsp ==# 'tsserver' + call ale#lsp#Send(l:conn_id, ale#lsp#tsserver_message#Change(a:buffer)) + endif + + return { + \ 'connection_id': l:conn_id, + \ 'command': l:command, + \ 'project_root': l:root, + \ 'language_id': l:language_id, + \} +endfunction diff --git a/autoload/ale/lsp.vim b/autoload/ale/lsp.vim index 083a27e..2c9b299 100644 --- a/autoload/ale/lsp.vim +++ b/autoload/ale/lsp.vim @@ -11,10 +11,13 @@ function! s:NewConnection() abort " data: The message data received so far. " executable: An executable only set for program connections. " open_documents: A list of buffers we told the server we opened. + " callback_list: A list of callbacks for handling LSP responses. let l:conn = { \ 'id': '', \ 'data': '', + \ 'projects': {}, \ 'open_documents': [], + \ 'callback_list': [], \} call add(s:connections, l:conn) @@ -141,6 +144,35 @@ function! ale#lsp#ReadMessageData(data) abort return [l:remainder, l:response_list] endfunction +function! s:FindProjectWithInitRequestID(conn, init_request_id) abort + for l:project_root in keys(a:conn.projects) + let l:project = a:conn.projects[l:project_root] + + if l:project.init_request_id == a:init_request_id + return l:project + endif + endfor + + return {} +endfunction + +function! s:HandleInitializeResponse(conn, response) abort + let l:request_id = a:response.request_id + let l:project = s:FindProjectWithInitRequestID(a:conn, l:request_id) + + if empty(l:project) + return + endif + + " After the server starts, send messages we had queued previously. + for l:message_data in l:project.message_queue + call s:SendMessageData(a:conn, l:message_data) + endfor + + " Remove the messages now. + let a:conn.message_queue = [] +endfunction + function! ale#lsp#HandleMessage(conn, message) abort let a:conn.data .= a:message @@ -149,8 +181,13 @@ function! ale#lsp#HandleMessage(conn, message) abort " Call our callbacks. for l:response in l:response_list - if has_key(a:conn, 'callback') - call ale#util#GetFunction(a:conn.callback)(l:response) + if get(l:response, 'method', '') ==# 'initialize' + call s:HandleInitializeResponse(a:conn, l:response) + else + " Call all of the registered handlers with the response. + for l:Callback in a:conn.callback_list + call ale#util#GetFunction(l:Callback)(l:response) + endfor endif endfor endfunction @@ -169,11 +206,22 @@ function! s:HandleCommandMessage(job_id, message) abort call ale#lsp#HandleMessage(l:conn, a:message) endfunction +function! s:RegisterProject(conn, project_root) abort + if !has_key(a:conn, a:project_root) + " Tools without project roots are ready right away, like tsserver. + let a:conn.projects[a:project_root] = { + \ 'initialized': empty(a:project_root), + \ 'init_messsage_id': 0, + \ 'message_queue': [], + \} + endif +endfunction + " Start a program for LSP servers which run with executables. " " The job ID will be returned for for the program if it ran, otherwise " 0 will be returned. -function! ale#lsp#StartProgram(executable, command, callback) abort +function! ale#lsp#StartProgram(executable, command, project_root, callback) abort if !executable(a:executable) return 0 endif @@ -199,13 +247,15 @@ function! ale#lsp#StartProgram(executable, command, callback) abort endif let l:conn.id = l:job_id - let l:conn.callback = a:callback + " Add the callback to the List if it's not there already. + call uniq(sort(add(l:conn.callback_list, a:callback))) + call s:RegisterProject(l:conn, a:project_root) return l:job_id endfunction " Connect to an address and set up a callback for handling responses. -function! ale#lsp#ConnectToAddress(address, callback) abort +function! ale#lsp#ConnectToAddress(address, project_root, callback) abort let l:conn = s:FindConnection('id', a:address) " Get the current connection or a new one. let l:conn = !empty(l:conn) ? l:conn : s:NewConnection() @@ -223,7 +273,22 @@ function! ale#lsp#ConnectToAddress(address, callback) abort endif let l:conn.id = a:address - let l:conn.callback = a:callback + " Add the callback to the List if it's not there already. + call uniq(sort(add(l:conn.callback_list, a:callback))) + call s:RegisterProject(l:conn, a:project_root) + + return 1 +endfunction + +function! s:SendMessageData(conn, data) abort + if has_key(a:conn, 'executable') + call ale#job#SendRaw(a:conn.id, a:data) + elseif has_key(a:conn, 'channel') && ch_status(a:conn.channnel) ==# 'open' + " Send the message to the server + call ch_sendraw(a:conn.channel, a:data) + else + return 0 + endif return 1 endfunction @@ -234,28 +299,60 @@ endfunction " Returns -1 when a message is sent, but no response is expected " 0 when the message is not sent and " >= 1 with the message ID when a response is expected. -function! ale#lsp#Send(conn_id, message) abort +function! ale#lsp#Send(conn_id, message, ...) abort + let l:project_root = get(a:000, 0, '') + let l:conn = s:FindConnection('id', a:conn_id) + + if empty(l:conn) + return 0 + endif + + let l:project = get(l:conn.projects, l:project_root, {}) + + if empty(l:project) + return 0 + endif + + " If we haven't initialized the server yet, then send the message for it. + if !l:project.initialized + " Only send the init message once. + if !l:project.init_request_id + let [l:init_id, l:init_data] = ale#lsp#CreateMessageData( + \ ale#lsp#message#Initialize(l:conn.project_root), + \) + + let l:project.init_request_id = l:init_id + + call s:SendMessageData(l:conn, l:init_data) + endif + endif + let [l:id, l:data] = ale#lsp#CreateMessageData(a:message) - if has_key(l:conn, 'executable') - call ale#job#SendRaw(l:conn.id, l:data) - elseif has_key(l:conn, 'channel') && ch_status(l:conn.channnel) ==# 'open' - " Send the message to the server - call ch_sendraw(l:conn.channel, l:data) + if l:project.initialized + " Send the message now. + call s:SendMessageData(l:conn, l:data) else - return 0 + " Add the message we wanted to send to a List to send later. + call add(l:project.message_queue, l:data) endif return l:id == 0 ? -1 : l:id endfunction -function! ale#lsp#OpenTSServerDocumentIfNeeded(conn_id, buffer) abort +function! ale#lsp#OpenDocumentIfNeeded(conn_id, buffer, project_root, language_id) abort let l:conn = s:FindConnection('id', a:conn_id) let l:opened = 0 if !empty(l:conn) && index(l:conn.open_documents, a:buffer) < 0 - call ale#lsp#Send(a:conn_id, ale#lsp#tsserver_message#Open(a:buffer)) + if empty(a:language_id) + let l:message = ale#lsp#tsserver_message#Open(a:buffer) + else + let l:message = ale#lsp#message#DidOpen(a:buffer, a:language_id) + endif + + call ale#lsp#Send(a:conn_id, l:message, a:project_root) call add(l:conn.open_documents, a:buffer) let l:opened = 1 endif diff --git a/autoload/ale/lsp/message.vim b/autoload/ale/lsp/message.vim index 937e4f4..7910247 100644 --- a/autoload/ale/lsp/message.vim +++ b/autoload/ale/lsp/message.vim @@ -3,12 +3,32 @@ " " Messages in this movie will be returned in the format " [is_notification, method_name, params?] +let g:ale_lsp_next_version_id = 1 -function! ale#lsp#message#Initialize(root_uri) abort +" The LSP protocols demands that we send every change to a document, including +" undo, with incrementing version numbers, so we'll just use one incrementing +" ID for everything. +function! ale#lsp#message#GetNextVersionID() abort + " Use the current ID + let l:id = g:ale_lsp_next_version_id + + " Increment the ID variable. + let g:ale_lsp_next_version_id += 1 + + " When the ID overflows, reset it to 1. By the time we hit the initial ID + " again, the messages will be long gone. + if g:ale_lsp_next_version_id < 1 + let g:ale_lsp_next_version_id = 1 + endif + + return l:id +endfunction + +function! ale#lsp#message#Initialize(root_path) abort " TODO: Define needed capabilities. return [0, 'initialize', { \ 'processId': getpid(), - \ 'rootUri': a:root_uri, + \ 'rootPath': a:root_path, \ 'capabilities': {}, \}] endfunction @@ -25,40 +45,44 @@ function! ale#lsp#message#Exit() abort return [1, 'exit'] endfunction -function! ale#lsp#message#DidOpen(uri, language_id, version, text) abort +function! ale#lsp#message#DidOpen(buffer, language_id) abort + let l:lines = getbufline(a:buffer, 1, '$') + return [1, 'textDocument/didOpen', { \ 'textDocument': { - \ 'uri': a:uri, + \ 'uri': ale#path#ToURI(expand('#' . a:buffer . ':p')), \ 'languageId': a:language_id, - \ 'version': a:version, - \ 'text': a:text, + \ 'version': ale#lsp#message#GetNextVersionID(), + \ 'text': join(l:lines, "\n"), \ }, \}] endfunction -function! ale#lsp#message#DidChange(uri, version, text) abort +function! ale#lsp#message#DidChange(buffer) abort + let l:lines = getbufline(a:buffer, 1, '$') + " For changes, we simply send the full text of the document to the server. return [1, 'textDocument/didChange', { \ 'textDocument': { - \ 'uri': a:uri, - \ 'version': a:version, + \ 'uri': ale#path#ToURI(expand('#' . a:buffer . ':p')), + \ 'version': ale#lsp#message#GetNextVersionID(), \ }, - \ 'contentChanges': [{'text': a:text}] + \ 'contentChanges': [{'text': join(l:lines, "\n")}] \}] endfunction -function! ale#lsp#message#DidSave(uri) abort +function! ale#lsp#message#DidSave(buffer) abort return [1, 'textDocument/didSave', { \ 'textDocument': { - \ 'uri': a:uri, + \ 'uri': ale#path#ToURI(expand('#' . a:buffer . ':p')), \ }, \}] endfunction -function! ale#lsp#message#DidClose(uri) abort +function! ale#lsp#message#DidClose(buffer) abort return [1, 'textDocument/didClose', { \ 'textDocument': { - \ 'uri': a:uri, + \ 'uri': ale#path#ToURI(expand('#' . a:buffer . ':p')), \ }, \}] endfunction diff --git a/autoload/ale/lsp/response.vim b/autoload/ale/lsp/response.vim index a2146f6..13219ef 100644 --- a/autoload/ale/lsp/response.vim +++ b/autoload/ale/lsp/response.vim @@ -8,11 +8,10 @@ let s:SEVERITY_INFORMATION = 3 let s:SEVERITY_HINT = 4 " Parse the message for textDocument/publishDiagnostics -function! ale#lsp#response#ReadDiagnostics(params) abort - let l:filename = a:params.uri +function! ale#lsp#response#ReadDiagnostics(response) abort let l:loclist = [] - for l:diagnostic in a:params.diagnostics + for l:diagnostic in a:response.params.diagnostics let l:severity = get(l:diagnostic, 'severity', 0) let l:loclist_item = { \ 'text': l:diagnostic.message, @@ -40,7 +39,7 @@ function! ale#lsp#response#ReadDiagnostics(params) abort call add(l:loclist, l:loclist_item) endfor - return [l:filename, l:loclist] + return l:loclist endfunction function! ale#lsp#response#ReadTSServerDiagnostics(response) abort diff --git a/autoload/ale/path.vim b/autoload/ale/path.vim index 9ac3d8f..c68114a 100644 --- a/autoload/ale/path.vim +++ b/autoload/ale/path.vim @@ -141,3 +141,26 @@ function! ale#path#Upwards(path) abort return l:path_list endfunction + +" Convert a filesystem path to a file:// URI +" relatives paths will not be prefixed with the protocol. +" For Windows paths, the `:` in C:\ etc. will not be percent-encoded. +function! ale#path#ToURI(path) abort + let l:has_drive_letter = a:path[1:2] ==# ':\' + + return substitute( + \ ((l:has_drive_letter || a:path[:0] ==# '/') ? 'file://' : '') + \ . (l:has_drive_letter ? '/' . a:path[:2] : '') + \ . ale#uri#Encode(l:has_drive_letter ? a:path[3:] : a:path), + \ '\\', + \ '/', + \ 'g', + \) +endfunction + +function! ale#path#FromURI(uri) abort + let l:i = len('file://') + let l:encoded_path = a:uri[: l:i - 1] ==# 'file://' ? a:uri[l:i :] : a:uri + + return ale#uri#Decode(l:encoded_path) +endfunction diff --git a/autoload/ale/uri.vim b/autoload/ale/uri.vim new file mode 100644 index 0000000..934637d --- /dev/null +++ b/autoload/ale/uri.vim @@ -0,0 +1,18 @@ +" This probably doesn't handle Unicode characters well. +function! ale#uri#Encode(value) abort + return substitute( + \ a:value, + \ '\([^a-zA-Z0-9\\/$\-_.!*''(),]\)', + \ '\=printf(''%%%02x'', char2nr(submatch(1)))', + \ 'g' + \) +endfunction + +function! ale#uri#Decode(value) abort + return substitute( + \ a:value, + \ '%\(\x\x\)', + \ '\=nr2char(''0x'' . submatch(1))', + \ 'g' + \) +endfunction diff --git a/test/lsp/test_lsp_client_messages.vader b/test/lsp/test_lsp_client_messages.vader index abf733c..057abad 100644 --- a/test/lsp/test_lsp_client_messages.vader +++ b/test/lsp/test_lsp_client_messages.vader @@ -1,10 +1,13 @@ Before: silent! cd /testplugin/test/lsp - let b:dir = getcwd() + let g:dir = getcwd() + let g:ale_lsp_next_version_id = 1 + + call ale#test#SetFilename('foo/bar.ts') After: - silent execute 'cd ' . fnameescape(b:dir) - unlet! b:dir + silent execute 'cd ' . fnameescape(g:dir) + unlet! g:dir Execute(ale#lsp#message#Initialize() should return correct messages): AssertEqual @@ -13,7 +16,7 @@ Execute(ale#lsp#message#Initialize() should return correct messages): \ 'initialize', \ { \ 'processId': getpid(), - \ 'rootUri': '/foo/bar', + \ 'rootPath': '/foo/bar', \ 'capabilities': {}, \ } \ ], @@ -28,36 +31,51 @@ Execute(ale#lsp#message#Shutdown() should return correct messages): Execute(ale#lsp#message#Exit() should return correct messages): AssertEqual [1, 'exit'], ale#lsp#message#Exit(), +Given typescript(A TypeScript file with 3 lines): + foo() + bar() + baz() + Execute(ale#lsp#message#DidOpen() should return correct messages): + let g:ale_lsp_next_version_id = 12 AssertEqual \ [ \ 1, \ 'textDocument/didOpen', \ { \ 'textDocument': { - \ 'uri': '/foo/bar', + \ 'uri': 'file://' . g:dir . '/foo/bar.ts', \ 'languageId': 'typescript', - \ 'version': 123, - \ 'text': 'foobar', + \ 'version': 12, + \ 'text': "foo()\nbar()\nbaz()", \ }, \ } \ ], - \ ale#lsp#message#DidOpen('/foo/bar', 'typescript', 123, 'foobar') + \ ale#lsp#message#DidOpen(bufnr(''), 'typescript') Execute(ale#lsp#message#DidChange() should return correct messages): + let g:ale_lsp_next_version_id = 34 + AssertEqual \ [ \ 1, \ 'textDocument/didChange', \ { \ 'textDocument': { - \ 'uri': '/foo/bar', - \ 'version': 123, + \ 'uri': 'file://' . g:dir . '/foo/bar.ts', + \ 'version': 34, \ }, - \ 'contentChanges': [{'text': 'foobar'}], + \ 'contentChanges': [{'text': "foo()\nbar()\nbaz()"}], \ } \ ], - \ ale#lsp#message#DidChange('/foo/bar', 123, 'foobar') + \ ale#lsp#message#DidChange(bufnr('')) + " The version numbers should increment. + AssertEqual + \ 35, + \ ale#lsp#message#DidChange(bufnr(''))[2].textDocument.version + AssertEqual + \ 36, + \ ale#lsp#message#DidChange(bufnr(''))[2].textDocument.version Execute(ale#lsp#message#DidSave() should return correct messages): AssertEqual @@ -66,11 +84,11 @@ Execute(ale#lsp#message#DidSave() should return correct messages): \ 'textDocument/didSave', \ { \ 'textDocument': { - \ 'uri': '/foo/bar', + \ 'uri': 'file://' . g:dir . '/foo/bar.ts', \ }, \ } \ ], - \ ale#lsp#message#DidSave('/foo/bar') + \ ale#lsp#message#DidSave(bufnr('')) Execute(ale#lsp#message#DidClose() should return correct messages): AssertEqual @@ -79,52 +97,41 @@ Execute(ale#lsp#message#DidClose() should return correct messages): \ 'textDocument/didClose', \ { \ 'textDocument': { - \ 'uri': '/foo/bar', + \ 'uri': 'file://' . g:dir . '/foo/bar.ts', \ }, \ } \ ], - \ ale#lsp#message#DidClose('/foo/bar') + \ ale#lsp#message#DidClose(bufnr('')) Execute(ale#lsp#tsserver_message#Open() should return correct messages): - silent! noautocmd file foo.ts - AssertEqual \ [ \ 1, \ 'ts@open', \ { - \ 'file': b:dir . '/foo.ts', + \ 'file': g:dir . '/foo/bar.ts', \ } \ ], \ ale#lsp#tsserver_message#Open(bufnr('')) Execute(ale#lsp#tsserver_message#Close() should return correct messages): - silent! noautocmd file foo.ts - AssertEqual \ [ \ 1, \ 'ts@close', \ { - \ 'file': b:dir . '/foo.ts', + \ 'file': g:dir . '/foo/bar.ts', \ } \ ], \ ale#lsp#tsserver_message#Close(bufnr('')) -Given typescript(A TypeScript file with 3 lines): - foo() - bar() - baz() - Execute(ale#lsp#tsserver_message#Change() should return correct messages): - silent! noautocmd file foo.ts - AssertEqual \ [ \ 1, \ 'ts@change', \ { - \ 'file': b:dir . '/foo.ts', + \ 'file': g:dir . '/foo/bar.ts', \ 'line': 1, \ 'offset': 1, \ 'endLine': 1073741824, @@ -135,27 +142,23 @@ Execute(ale#lsp#tsserver_message#Change() should return correct messages): \ ale#lsp#tsserver_message#Change(bufnr('')) Execute(ale#lsp#tsserver_message#Geterr() should return correct messages): - silent! noautocmd file foo.ts - AssertEqual \ [ \ 1, \ 'ts@geterr', \ { - \ 'files': [b:dir . '/foo.ts'], + \ 'files': [g:dir . '/foo/bar.ts'], \ } \ ], \ ale#lsp#tsserver_message#Geterr(bufnr('')) Execute(ale#lsp#tsserver_message#Completions() should return correct messages): - silent! noautocmd file foo.ts - AssertEqual \ [ \ 0, \ 'ts@completions', \ { - \ 'file': b:dir . '/foo.ts', + \ 'file': g:dir . '/foo/bar.ts', \ 'line': 347, \ 'offset': 12, \ 'prefix': 'abc', @@ -164,14 +167,12 @@ Execute(ale#lsp#tsserver_message#Completions() should return correct messages): \ ale#lsp#tsserver_message#Completions(bufnr(''), 347, 12, 'abc') Execute(ale#lsp#tsserver_message#CompletionEntryDetails() should return correct messages): - silent! noautocmd file foo.ts - AssertEqual \ [ \ 0, \ 'ts@completionEntryDetails', \ { - \ 'file': b:dir . '/foo.ts', + \ 'file': g:dir . '/foo/bar.ts', \ 'line': 347, \ 'offset': 12, \ 'entryNames': ['foo', 'bar'], diff --git a/test/lsp/test_read_lsp_diagnostics.vader b/test/lsp/test_read_lsp_diagnostics.vader index 63086a7..3e63741 100644 --- a/test/lsp/test_read_lsp_diagnostics.vader +++ b/test/lsp/test_read_lsp_diagnostics.vader @@ -10,7 +10,7 @@ After: delfunction Range Execute(ale#lsp#response#ReadDiagnostics() should handle errors): - AssertEqual ['filename.ts', [ + AssertEqual [ \ { \ 'type': 'E', \ 'text': 'Something went wrong!', @@ -20,18 +20,18 @@ Execute(ale#lsp#response#ReadDiagnostics() should handle errors): \ 'end_col': 16, \ 'nr': 'some-error', \ } - \ ]], - \ ale#lsp#response#ReadDiagnostics({'uri': 'filename.ts', 'diagnostics': [ + \ ], + \ ale#lsp#response#ReadDiagnostics({'params': {'uri': 'filename.ts', 'diagnostics': [ \ { \ 'severity': 1, \ 'range': Range(2, 10, 4, 15), \ 'code': 'some-error', \ 'message': 'Something went wrong!', \ }, - \ ]}) + \ ]}}) Execute(ale#lsp#response#ReadDiagnostics() should handle warnings): - AssertEqual ['filename.ts', [ + AssertEqual [ \ { \ 'type': 'W', \ 'text': 'Something went wrong!', @@ -41,18 +41,18 @@ Execute(ale#lsp#response#ReadDiagnostics() should handle warnings): \ 'end_col': 4, \ 'nr': 'some-warning', \ } - \ ]], - \ ale#lsp#response#ReadDiagnostics({'uri': 'filename.ts', 'diagnostics': [ + \ ], + \ ale#lsp#response#ReadDiagnostics({'params': {'uri': 'filename.ts', 'diagnostics': [ \ { \ 'severity': 2, \ 'range': Range(1, 3, 1, 3), \ 'code': 'some-warning', \ 'message': 'Something went wrong!', \ }, - \ ]}) + \ ]}}) Execute(ale#lsp#response#ReadDiagnostics() should treat messages with missing severity as errors): - AssertEqual ['filename.ts', [ + AssertEqual [ \ { \ 'type': 'E', \ 'text': 'Something went wrong!', @@ -62,17 +62,17 @@ Execute(ale#lsp#response#ReadDiagnostics() should treat messages with missing se \ 'end_col': 16, \ 'nr': 'some-error', \ } - \ ]], - \ ale#lsp#response#ReadDiagnostics({'uri': 'filename.ts', 'diagnostics': [ + \ ], + \ ale#lsp#response#ReadDiagnostics({'params': {'uri': 'filename.ts', 'diagnostics': [ \ { \ 'range': Range(2, 10, 4, 15), \ 'code': 'some-error', \ 'message': 'Something went wrong!', \ }, - \ ]}) + \ ]}}) Execute(ale#lsp#response#ReadDiagnostics() should handle messages without codes): - AssertEqual ['filename.ts', [ + AssertEqual [ \ { \ 'type': 'E', \ 'text': 'Something went wrong!', @@ -81,16 +81,16 @@ Execute(ale#lsp#response#ReadDiagnostics() should handle messages without codes) \ 'end_lnum': 5, \ 'end_col': 16, \ } - \ ]], - \ ale#lsp#response#ReadDiagnostics({'uri': 'filename.ts', 'diagnostics': [ + \ ], + \ ale#lsp#response#ReadDiagnostics({'params': {'uri': 'filename.ts', 'diagnostics': [ \ { \ 'range': Range(2, 10, 4, 15), \ 'message': 'Something went wrong!', \ }, - \ ]}) + \ ]}}) Execute(ale#lsp#response#ReadDiagnostics() should handle multiple messages): - AssertEqual ['filename.ts', [ + AssertEqual [ \ { \ 'type': 'E', \ 'text': 'Something went wrong!', @@ -107,8 +107,8 @@ Execute(ale#lsp#response#ReadDiagnostics() should handle multiple messages): \ 'end_lnum': 2, \ 'end_col': 5, \ }, - \ ]], - \ ale#lsp#response#ReadDiagnostics({'uri': 'filename.ts', 'diagnostics': [ + \ ], + \ ale#lsp#response#ReadDiagnostics({'params': {'uri': 'filename.ts', 'diagnostics': [ \ { \ 'range': Range(0, 2, 0, 2), \ 'message': 'Something went wrong!', @@ -118,7 +118,7 @@ Execute(ale#lsp#response#ReadDiagnostics() should handle multiple messages): \ 'range': Range(1, 4, 1, 4), \ 'message': 'A warning', \ }, - \ ]}) + \ ]}}) Execute(ale#lsp#response#ReadTSServerDiagnostics() should handle tsserver responses): AssertEqual [ diff --git a/test/test_linter_defintion_processing.vader b/test/test_linter_defintion_processing.vader index 572591d..d946a60 100644 --- a/test/test_linter_defintion_processing.vader +++ b/test/test_linter_defintion_processing.vader @@ -372,6 +372,8 @@ Execute(PreProcess should accept tsserver LSP configuration): \ 'executable': 'x', \ 'command': 'x', \ 'lsp': 'tsserver', + \ 'language_callback': 'x', + \ 'project_root_callback': 'x', \} AssertEqual 'tsserver', ale#linter#PreProcess(g:linter).lsp @@ -392,6 +394,8 @@ Execute(PreProcess should accept stdio LSP configuration): \ 'executable': 'x', \ 'command': 'x', \ 'lsp': 'stdio', + \ 'language_callback': 'x', + \ 'project_root_callback': 'x', \} AssertEqual 'stdio', ale#linter#PreProcess(g:linter).lsp @@ -411,6 +415,8 @@ Execute(PreProcess should accept LSP server configurations): \ 'name': 'x', \ 'lsp': 'socket', \ 'address_callback': 'X', + \ 'language_callback': 'x', + \ 'project_root_callback': 'x', \} AssertEqual 'socket', ale#linter#PreProcess(g:linter).lsp diff --git a/test/test_path_uri.vader b/test/test_path_uri.vader new file mode 100644 index 0000000..dbceac3 --- /dev/null +++ b/test/test_path_uri.vader @@ -0,0 +1,16 @@ +Execute(ale#path#ToURI should work for Windows paths): + AssertEqual 'file:///C:/foo/bar/baz.tst', ale#path#ToURI('C:\foo\bar\baz.tst') + AssertEqual 'foo/bar/baz.tst', ale#path#ToURI('foo\bar\baz.tst') + +Execute(ale#path#ToURI should work for Unix paths): + AssertEqual 'file:///foo/bar/baz.tst', ale#path#ToURI('/foo/bar/baz.tst') + AssertEqual 'foo/bar/baz.tst', ale#path#ToURI('foo/bar/baz.tst') + +Execute(ale#path#ToURI should keep safe characters): + AssertEqual '//a-zA-Z0-9$-_.!*''(),', ale#path#ToURI('\/a-zA-Z0-9$-_.!*''(),') + +Execute(ale#path#ToURI should percent encode unsafe characters): + AssertEqual '%20%2b%3a%3f%26%3d', ale#path#ToURI(' +:?&=') + +Execute(ale#path#FromURI should decode percent encodings): + AssertEqual ' +:?&=', ale#path#FromURI('%20%2b%3a%3f%26%3d') diff --git a/test/util/test_cd_string_commands.vader b/test/util/test_cd_string_commands.vader index b0b6c15..f8a97cb 100644 --- a/test/util/test_cd_string_commands.vader +++ b/test/util/test_cd_string_commands.vader @@ -1,8 +1,15 @@ Before: silent! cd /testplugin/test/util + let g:dir = getcwd() + +After: + silent execute 'cd ' . fnameescape(g:dir) + unlet! g:dir Execute(CdString should output the correct command string): AssertEqual 'cd ''/foo bar/baz'' && ', ale#path#CdString('/foo bar/baz') Execute(BufferCdString should output the correct command string): - AssertEqual 'cd ' . shellescape(getcwd()) . ' && ', ale#path#BufferCdString(bufnr('')) + call ale#test#SetFilename('foo.txt') + + AssertEqual 'cd ' . shellescape(g:dir) . ' && ', ale#path#BufferCdString(bufnr(''))