howl.lsp.lsp.Client

local process, client

before_each ->
  process = FakeProcess!
  client = Client name: 'fake', cmd: 'fake-server', root: File('/tmp/proj'), :process

initialize = (capabilities = { positionEncoding: 'utf-8' }) ->
  client\start!
  init = process\messages![1]
  process\emit { jsonrpc: '2.0', id: init.id, result: { :capabilities } }

closes stdin when the server exits

initialize!
process\exit!
assert.is_true process.stdin_closed

uses the request handlers given in opts

handler = -> 'ok'
client = Client name: 'fake', cmd: 'fake-server', root: File('/tmp/proj'), :process, request_handlers: { 'foo/bar': handler }
assert.equals handler, client.request_handlers['foo/bar']
assert.is_not_nil client.request_handlers['workspace/configuration']

start()

sends an initialize request with the root and utf-8 as position encoding

client\start!
msg = process\messages![1]
assert.equals 'initialize', msg.method
assert.equals 'file:///tmp/proj', msg.params.rootUri
assert.same { 'utf-8' }, msg.params.capabilities.general.positionEncodings

sends init_options as the initializationOptions, if given

client\start!
assert.is_nil process\messages![1].params.initializationOptions

process = FakeProcess!
options = { typeCheckingMode: 'mypy', nested: { flag: true } }
client = Client name: 'fake', cmd: 'fake-server', init_options: options, root: File('/tmp/proj'), :process
client\start!
assert.same options, process\messages![1].params.initializationOptions

advertises applying workspace edits and preparing renames

client\start!
capabilities = process\messages![1].params.capabilities
assert.is_true capabilities.workspace.applyEdit
assert.same { documentChanges: true, failureHandling: 'transactional' }, capabilities.workspace.workspaceEdit
assert.same { prepareSupport: true }, capabilities.textDocument.rename

sends initialized and stores the server capabilities once the server responds

initialize { positionEncoding: 'utf-8', completionProvider: {} }
assert.is_true client.initialized
assert.same {}, client.capabilities.completionProvider
assert.equals 'initialized', process\last_message!.method

holds back other messages until initialization is complete

client\start!
client\notify 'textDocument/didOpen', {}
assert.equals 1, #process\messages!
init = process\messages![1]
process\emit { jsonrpc: '2.0', id: init.id, result: { capabilities: { positionEncoding: 'utf-8' } } }
methods = [m.method for m in *process\messages!]
assert.same { 'initialize', 'initialized', 'textDocument/didOpen' }, methods

shuts down the server if it does not support the utf-8 position encoding

initialize {}
assert.is_false client.initialized
assert.is_true client.dead
assert.same { 'TERM' }, process.signals
assert.match client.failure, 'utf%-8'
assert.is_true process.stdin_closed

names the server by its name in log messages

warn = spy.on log, 'warn'
initialize {}
log.warn\revert!
assert.match warn.calls[1].vals[1], '^LSP %(fake%): '

last_used

before_each ->
  initialize!
  client.last_used = 0

is updated when sending a message

client\notify 'foo', {}
assert.is_true client.last_used > 0

is not updated when receiving a message

process\emit { jsonrpc: '2.0', method: 'foo', params: {} }
assert.equals 0, client.last_used

stop()

before_each -> initialize!

stop = ->
  client\stop!
  shutdown = process\last_message!
  assert.equals 'shutdown', shutdown.method
  process\emit { jsonrpc: '2.0', id: shutdown.id, result: json_rpc.null }

sends shutdown followed by exit

stop!
assert.equals 'exit', process\last_message!.method

closes stdin after sending exit, and sends nothing more

stop!
assert.is_true process.stdin_closed
nr_written = #process.written
client\notify 'foo', {}
assert.equals nr_written, #process.written

closes stdin only once what is queued has been written

writes = {}
process.stdin.write = (_, data) ->
  table.insert process.written, data
  handle = dispatch.park 'fake-write'
  table.insert writes, handle
  dispatch.wait handle

client\stop!
shutdown = process\last_message!
process\emit { jsonrpc: '2.0', id: shutdown.id, result: json_rpc.null }
assert.is_false process.stdin_closed
dispatch.resume writes[1]
assert.equals 'exit', process\last_message!.method
assert.is_false process.stdin_closed
dispatch.resume writes[2]
assert.is_true process.stdin_closed

does not send shutdown again when already stopping

client\stop!
client\stop!
shutdowns = [m for m in *process\messages! when m.method == 'shutdown']
assert.equals 1, #shutdowns

is invoked when the process is stopped

process.stop_handler!
assert.equals 'shutdown', process\last_message!.method

does not warn when the server then exits

warn = spy.on log, 'warn'
client\stop!
process\exit!
log.warn\revert!
assert.spy(warn).was_not_called!
assert.is_nil client.failure

send_request(method, params, callback)

before_each -> initialize!

invokes callback with the result of the matching response

callback = spy.new ->
id = client\send_request 'foo', { x: 1 }, callback
msg = process\last_message!
assert.same { jsonrpc: '2.0', id: id, method: 'foo', params: { x: 1 } }, msg
process\emit { jsonrpc: '2.0', id: id + 100, result: 'other' }
assert.spy(callback).was_not_called!
process\emit { jsonrpc: '2.0', id: id, result: 'ok' }
assert.spy(callback).was_called_with 'ok'

invokes callback with nil and the message for error responses

callback = spy.new ->
id = client\send_request 'foo', {}, callback
process\emit { jsonrpc: '2.0', id: id, error: { code: 1, message: 'bad' } }
assert.spy(callback).was_called_with nil, 'bad'

invokes callback with an error when the request times out

callback = spy.new ->
client\send_request 'foo', {}, callback, 0
howl.app\pump_mainloop!
assert.spy(callback).was_called_with nil, "request 'foo' timed out"

fails pending requests when the server exits

callback = spy.new ->
client\send_request 'foo', {}, callback
process\exit!
assert.spy(callback).was_called_with nil, 'server is not running'
assert.is_true client.dead

returns nil and an error when the server is not running

process\exit!
assert.same { nil, 'server is not running' }, { client\send_request 'foo', {}, -> }

request(method, params)

waits for and returns the result

initialize!
local result
dispatch.launch -> result = client\request 'foo', {}
process\emit { jsonrpc: '2.0', id: process\last_message!.id, result: 'ok' }
assert.equals 'ok', result

cancel(id)

sends $/cancelRequest and ignores any later response

initialize!
callback = spy.new ->
id = client\send_request 'foo', {}, callback
client\cancel id
assert.same { method: '$/cancelRequest', params: { :id }, jsonrpc: '2.0' }, process\last_message!
process\emit { jsonrpc: '2.0', :id, result: 'late' }
assert.spy(callback).was_not_called!

server messages

before_each -> initialize!

invokes the registered notification handler with the params

handler = spy.new ->
client.notification_handlers['window/logMessage'] = handler
process\emit { jsonrpc: '2.0', method: 'window/logMessage', params: { message: 'hi' } }
assert.spy(handler).was_called_with { message: 'hi' }

responds to workspace/configuration requests with nulls

process\emit { jsonrpc: '2.0', id: 'x', method: 'workspace/configuration', params: { items: { {}, {} } } }
assert.includes process.written[#process.written], '"result":[null,null]'

responds with an error to unknown requests

process\emit { jsonrpc: '2.0', id: 7, method: 'foo/bar' }
msg = process\last_message!
assert.equals 7, msg.id
assert.equals -32601, msg.error.code

responds to requests with the result of the registered request handler

handler = spy.new -> { applied: true }
client.request_handlers['workspace/applyEdit'] = handler
process\emit { jsonrpc: '2.0', id: 8, method: 'workspace/applyEdit', params: { edit: {} } }
assert.spy(handler).was_called_with { edit: {} }
assert.same { jsonrpc: '2.0', id: 8, result: { applied: true } }, process\last_message!

responds with null when the request handler returns nothing

client.request_handlers['foo/bar'] = ->
process\emit { jsonrpc: '2.0', id: 9, method: 'foo/bar' }
assert.includes process.written[#process.written], '"result":null'

responds with an error when the request handler fails

client.request_handlers['foo/bar'] = -> error 'oops'
error_log = spy.on log, 'error'
process\emit { jsonrpc: '2.0', id: 10, method: 'foo/bar' }
log.error\revert!
msg = process\last_message!
assert.equals 10, msg.id
assert.equals -32603, msg.error.code
assert.match msg.error.message, 'oops'
assert.spy(error_log).was_called!