#830 Implement a socket wrapper API for use with LSP connections

This commit is contained in:
w0rp 2018-07-02 23:49:47 +01:00
parent b637b35ea8
commit 01c68fedd6
No known key found for this signature in database
GPG key ID: 0FC1ECAA8C81CD83
7 changed files with 340 additions and 36 deletions

View file

@ -26,34 +26,11 @@ function! s:KillHandler(timer) abort
call job_stop(l:job, 'kill') call job_stop(l:job, 'kill')
endfunction endfunction
" Note that jobs and IDs are the same thing on NeoVim.
function! ale#job#JoinNeovimOutput(job, last_line, data, mode, callback) abort
if a:mode is# 'raw'
call a:callback(a:job, join(a:data, "\n"))
return ''
endif
let l:lines = a:data[:-2]
if len(a:data) > 1
let l:lines[0] = a:last_line . l:lines[0]
let l:new_last_line = a:data[-1]
else
let l:new_last_line = a:last_line . get(a:data, 0, '')
endif
for l:line in l:lines
call a:callback(a:job, l:line)
endfor
return l:new_last_line
endfunction
function! s:NeoVimCallback(job, data, event) abort function! s:NeoVimCallback(job, data, event) abort
let l:info = s:job_map[a:job] let l:info = s:job_map[a:job]
if a:event is# 'stdout' if a:event is# 'stdout'
let l:info.out_cb_line = ale#job#JoinNeovimOutput( let l:info.out_cb_line = ale#util#JoinNeovimOutput(
\ a:job, \ a:job,
\ l:info.out_cb_line, \ l:info.out_cb_line,
\ a:data, \ a:data,
@ -61,7 +38,7 @@ function! s:NeoVimCallback(job, data, event) abort
\ ale#util#GetFunction(l:info.out_cb), \ ale#util#GetFunction(l:info.out_cb),
\) \)
elseif a:event is# 'stderr' elseif a:event is# 'stderr'
let l:info.err_cb_line = ale#job#JoinNeovimOutput( let l:info.err_cb_line = ale#util#JoinNeovimOutput(
\ a:job, \ a:job,
\ l:info.err_cb_line, \ l:info.err_cb_line,
\ a:data, \ a:data,

137
autoload/ale/socket.vim Normal file
View file

@ -0,0 +1,137 @@
" Author: w0rp <devw0rp@gmail.com>
" Description: APIs for working with asynchronous sockets, with an API
" normalised between Vim 8 and NeoVim. Socket connections only work in NeoVim
" 0.3+, and silently do nothing in earlier NeoVim versions.
"
" Important functions are described below. They are:
"
" ale#socket#Open(address, options) -> channel_id (>= 0 if successful)
" ale#socket#IsOpen(channel_id) -> 1 if open, 0 otherwise
" ale#socket#Close(channel_id)
" ale#socket#Send(channel_id, data)
let s:channel_map = get(s:, 'channel_map', {})
function! s:VimOutputCallback(channel, data) abort
let l:channel_id = ch_info(a:channel).id
" Only call the callbacks for jobs which are valid.
if l:channel_id >= 0 && has_key(s:channel_map, l:channel_id)
call ale#util#GetFunction(s:channel_map[l:channel_id].callback)(l:channel_id, a:data)
endif
endfunction
function! s:NeoVimOutputCallback(channel_id, data, event) abort
let l:info = s:channel_map[a:channel_id]
if a:event is# 'data'
let l:info.last_line = ale#util#JoinNeovimOutput(
\ a:channel_id,
\ l:info.last_line,
\ a:data,
\ l:info.mode,
\ ale#util#GetFunction(l:info.callback),
\)
endif
endfunction
" Open a socket for a given address. The following options are accepted:
"
" callback - A callback for receiving input. (required)
"
" A non-negative number representing a channel ID will be returned is the
" connection was successful. 0 is a valid channel ID in Vim, so test if the
" connection ID is >= 0.
function! ale#socket#Open(address, options) abort
let l:mode = get(a:options, 'mode', 'raw')
let l:Callback = a:options.callback
let l:channel_info = {
\ 'mode': l:mode,
\ 'callback': a:options.callback,
\}
if !has('nvim')
" Vim
let l:channel_info.channel = ch_open(a:address, {
\ 'mode': l:mode,
\ 'waittime': 0,
\ 'callback': function('s:VimOutputCallback'),
\})
let l:vim_info = ch_info(l:channel_info.channel)
let l:channel_id = !empty(l:vim_info) ? l:vim_info.id : -1
elseif exists('*chansend') && exists('*sockconnect')
" NeoVim 0.3+
try
let l:channel_id = sockconnect('tcp', a:address, {
\ 'on_data': function('s:NeoVimOutputCallback'),
\})
let l:channel_info.last_line = ''
catch /connection failed/
let l:channel_id = -1
endtry
" 0 means the connection failed some times in NeoVim, so make the ID
" invalid to match Vim.
if l:channel_id is 0
let l:channel_id = -1
endif
let l:channel_info.channel = l:channel_id
else
" Other Vim versions.
let l:channel_id = -1
endif
if l:channel_id >= 0
let s:channel_map[l:channel_id] = l:channel_info
endif
return l:channel_id
endfunction
" Return 1 is a channel is open, 0 otherwise.
function! ale#socket#IsOpen(channel_id) abort
if !has_key(s:channel_map, a:channel_id)
return 0
endif
if has('nvim')
" In NeoVim, we have to check if this channel is in the global list.
return index(map(nvim_list_chans(), 'v:val.id'), a:channel_id) >= 0
endif
let l:channel = s:channel_map[a:channel_id].channel
return ch_status(l:channel) is# 'open'
endfunction
" Close a socket, if it's still open.
function! ale#socket#Close(channel_id) abort
" IsRunning isn't called here, so we don't check nvim_list_chans()
if !has_key(s:channel_map, a:channel_id)
return 0
endif
let l:channel = remove(s:channel_map, a:channel_id).channel
if has('nvim')
silent! call chanclose(l:channel)
elseif ch_status(l:channel) is# 'open'
call ch_close(l:channel)
endif
endfunction
" Send some data to a socket.
function! ale#socket#Send(channel_id, data) abort
if !has_key(s:channel_map, a:channel_id)
return
endif
let l:channel = s:channel_map[a:channel_id].channel
if has('nvim')
call chansend(l:channel, a:data)
else
call ch_sendraw(l:channel, a:data)
endif
endfunction

View file

@ -46,6 +46,33 @@ if !exists('g:ale#util#nul_file')
endif endif
endif endif
" Given a job, a buffered line of data, a list of parts of lines, a mode data
" is being read in, and a callback, join the lines of output for a NeoVim job
" or socket together, and call the callback with the joined output.
"
" Note that jobs and IDs are the same thing on NeoVim.
function! ale#util#JoinNeovimOutput(job, last_line, data, mode, callback) abort
if a:mode is# 'raw'
call a:callback(a:job, join(a:data, "\n"))
return ''
endif
let l:lines = a:data[:-2]
if len(a:data) > 1
let l:lines[0] = a:last_line . l:lines[0]
let l:new_last_line = a:data[-1]
else
let l:new_last_line = a:last_line . get(a:data, 0, '')
endif
for l:line in l:lines
call a:callback(a:job, l:line)
endfor
return l:new_last_line
endfunction
" Return the number of lines for a given buffer. " Return the number of lines for a given buffer.
function! ale#util#GetLineCount(buffer) abort function! ale#util#GetLineCount(buffer) abort
return len(getbufline(a:buffer, 1, '$')) return len(getbufline(a:buffer, 1, '$'))

33
test/dumb_tcp_client.py Normal file
View file

@ -0,0 +1,33 @@
"""
This is just a script for testing that the dumb TCP server actually works
correctly, for verifying that problems with tests are in Vim. Pass the
same port number given to the test server to check that it's working.
"""
import socket
import sys
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
result = sock.connect_ex(('127.0.0.1', int(sys.argv[1])))
if result:
sock.close()
sys.exit("Couldn't connect to the socket!")
data_sent = 'x' * 1024
sock.send(data_sent)
data_received = sock.recv(1024)
if data_sent != data_received:
sock.close()
sys.exit("Data sent didn't match data received.")
sock.close()
print("Everything was just fine.")
if __name__ == "__main__":
main()

40
test/dumb_tcp_server.py Normal file
View file

@ -0,0 +1,40 @@
"""
This Python script creates a TCP server that does nothing but send its input
back to the client that connects to it. Only one argument must be given, a port
to bind to.
"""
import os
import socket
import sys
def main():
if len(sys.argv) < 2 or not sys.argv[1].isdigit():
sys.exit('You must specify a port number')
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('127.0.0.1', int(sys.argv[1])))
sock.listen(0)
pid = os.fork()
if pid:
print(pid)
sys.exit()
while True:
connection = sock.accept()[0]
connection.settimeout(5)
while True:
try:
connection.send(connection.recv(1024))
except socket.timeout:
break
connection.close()
if __name__ == "__main__":
main()

View file

@ -18,67 +18,67 @@ After:
delfunction RawCallback delfunction RawCallback
Execute (ALE should handle empty Lists for the lines): Execute (ALE should handle empty Lists for the lines):
let g:last_line = ale#job#JoinNeovimOutput(1, '', [], 'nl', function('LineCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, '', [], 'nl', function('LineCallback'))
AssertEqual [], g:lines AssertEqual [], g:lines
AssertEqual '', g:last_line AssertEqual '', g:last_line
Execute (ALE should pass on full lines for NeoVim): Execute (ALE should pass on full lines for NeoVim):
let g:last_line = ale#job#JoinNeovimOutput(1, '', ['x', 'y', ''], 'nl', function('LineCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, '', ['x', 'y', ''], 'nl', function('LineCallback'))
AssertEqual ['x', 'y'], g:lines AssertEqual ['x', 'y'], g:lines
AssertEqual '', g:last_line AssertEqual '', g:last_line
Execute (ALE should pass on a single long line): Execute (ALE should pass on a single long line):
let g:last_line = ale#job#JoinNeovimOutput(1, '', ['x'], 'nl', function('LineCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, '', ['x'], 'nl', function('LineCallback'))
AssertEqual [], g:lines AssertEqual [], g:lines
AssertEqual 'x', g:last_line AssertEqual 'x', g:last_line
Execute (ALE should handle just a single line of output): Execute (ALE should handle just a single line of output):
let g:last_line = ale#job#JoinNeovimOutput(1, '', ['x', ''], 'nl', function('LineCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, '', ['x', ''], 'nl', function('LineCallback'))
AssertEqual ['x'], g:lines AssertEqual ['x'], g:lines
AssertEqual '', g:last_line AssertEqual '', g:last_line
Execute (ALE should join two incomplete pieces of large lines together): Execute (ALE should join two incomplete pieces of large lines together):
let g:last_line = ale#job#JoinNeovimOutput(1, 'x', ['y'], 'nl', function('LineCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, 'x', ['y'], 'nl', function('LineCallback'))
AssertEqual [], g:lines AssertEqual [], g:lines
AssertEqual 'xy', g:last_line AssertEqual 'xy', g:last_line
Execute (ALE join incomplete lines, and set new ones): Execute (ALE join incomplete lines, and set new ones):
let g:last_line = ale#job#JoinNeovimOutput(1, 'x', ['y', 'z', 'a'], 'nl', function('LineCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, 'x', ['y', 'z', 'a'], 'nl', function('LineCallback'))
AssertEqual ['xy', 'z'], g:lines AssertEqual ['xy', 'z'], g:lines
AssertEqual 'a', g:last_line AssertEqual 'a', g:last_line
Execute (ALE join incomplete lines, and set new ones, with two elements): Execute (ALE join incomplete lines, and set new ones, with two elements):
let g:last_line = ale#job#JoinNeovimOutput(1, 'x', ['y', 'z'], 'nl', function('LineCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, 'x', ['y', 'z'], 'nl', function('LineCallback'))
AssertEqual ['xy'], g:lines AssertEqual ['xy'], g:lines
AssertEqual 'z', g:last_line AssertEqual 'z', g:last_line
Execute (ALE should pass on full lines for NeoVim for raw data): Execute (ALE should pass on full lines for NeoVim for raw data):
let g:last_line = ale#job#JoinNeovimOutput(1, '', ['x', 'y', ''], 'raw', function('RawCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, '', ['x', 'y', ''], 'raw', function('RawCallback'))
AssertEqual "x\ny\n", g:data AssertEqual "x\ny\n", g:data
AssertEqual '', g:last_line AssertEqual '', g:last_line
Execute (ALE should pass on a single long line): Execute (ALE should pass on a single long line):
let g:last_line = ale#job#JoinNeovimOutput(1, '', ['x'], 'raw', function('RawCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, '', ['x'], 'raw', function('RawCallback'))
AssertEqual 'x', g:data AssertEqual 'x', g:data
AssertEqual '', g:last_line AssertEqual '', g:last_line
Execute (ALE should handle just a single line of output): Execute (ALE should handle just a single line of output):
let g:last_line = ale#job#JoinNeovimOutput(1, '', ['x', ''], 'raw', function('RawCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, '', ['x', ''], 'raw', function('RawCallback'))
AssertEqual "x\n", g:data AssertEqual "x\n", g:data
AssertEqual '', g:last_line AssertEqual '', g:last_line
Execute (ALE should pass on two lines and one incomplete one): Execute (ALE should pass on two lines and one incomplete one):
let g:last_line = ale#job#JoinNeovimOutput(1, '', ['y', 'z', 'a'], 'raw', function('RawCallback')) let g:last_line = ale#util#JoinNeovimOutput(1, '', ['y', 'z', 'a'], 'raw', function('RawCallback'))
AssertEqual "y\nz\na", g:data AssertEqual "y\nz\na", g:data
AssertEqual '', g:last_line AssertEqual '', g:last_line

View file

@ -0,0 +1,90 @@
Before:
let g:can_run_socket_tests = !has('win32')
\ && (exists('*ch_close') || exists('*chanclose'))
if g:can_run_socket_tests
call ale#test#SetDirectory('/testplugin/test')
let g:channel_id_received = 0
let g:data_received = ''
function! WaitForData(expected_data, timeout) abort
let l:ticks = 0
while l:ticks < a:timeout
" Sleep first, so we can switch to the callback.
let l:ticks += 10
sleep 10ms
if g:data_received is# a:expected_data
break
endif
endwhile
endfunction
function! TestCallback(channel_id, data) abort
let g:channel_id_received = a:channel_id
let g:data_received .= a:data
endfunction
let g:port = 10347
let g:pid = str2nr(system(
\ 'python'
\ . ' ' . ale#Escape(g:dir . '/dumb_tcp_server.py')
\ . ' ' . g:port
\))
endif
After:
if g:can_run_socket_tests
call ale#test#RestoreDirectory()
unlet! g:channel_id_received
unlet! g:data_received
unlet! g:channel_id
delfunction WaitForData
delfunction TestCallback
if has_key(g:, 'pid')
call system('kill ' . g:pid)
endif
unlet! g:pid
unlet! g:port
endif
unlet! g:can_run_socket_tests
Execute(Sending and receiving connections to sockets should work):
if g:can_run_socket_tests
let g:channel_id = ale#socket#Open(
\ '127.0.0.1:' . g:port,
\ {'callback': function('TestCallback')}
\)
Assert g:channel_id >= 0, 'The socket was not opened!'
call ale#socket#Send(g:channel_id, 'hello')
call ale#socket#Send(g:channel_id, ' world')
AssertEqual 1, ale#socket#IsOpen(g:channel_id)
" Wait up to 1 second for the expected data to arrive.
call WaitForData('hello world', 1000)
AssertEqual g:channel_id, g:channel_id_received
AssertEqual 'hello world', g:data_received
call ale#socket#Close(g:channel_id)
AssertEqual 0, ale#socket#IsOpen(g:channel_id)
endif
" NeoVim versions which can't connect to sockets should just fail.
if has('nvim') && !exists('*chanclose')
AssertEqual -1, ale#socket#Open(
\ '127.0.0.1:1111',
\ {'callback': function('function')}
\)
endif