howl.lsp.lsp
local buffer, client, state
before_each ->
buffer = Buffer {}
client = notify: spy.new ->
state = { :client, uri: 'file:///tmp/x.py', version: 1, dirty: false, changes: {}, full: false }
position(buffer, pos)
returns the zero-based line and utf-8 byte column for pos
buffer.text = 'åäö\nxÅy'
assert.same { line: 0, character: 0 }, lsp.position(buffer, 1)
assert.same { line: 0, character: 4 }, lsp.position(buffer, 3)
assert.same { line: 1, character: 3 }, lsp.position(buffer, 7)
command_for(buffer)
use_servers = (servers) ->
mode.register name: 'lsp-test', create: -> lsp_servers: servers
buffer.mode = mode.by_name 'lsp-test'
before_each -> use_servers { 'howl-no-such-server --stdio', 'true', 'false' }
after_each -> mode.unregister 'lsp-test'
returns the first installed server of the mode's lsp_servers
assert.equals 'true', lsp.command_for buffer
returns nil when none of the servers are installed
use_servers { 'howl-no-such-server' }
assert.is_nil lsp.command_for buffer
returns nil when the mode has no lsp_servers
buffer.mode = mode.by_name 'default'
assert.is_nil lsp.command_for buffer
buffer.config.lsp_command = 'howl-no-such-server --stdio'
assert.equals 'howl-no-such-server --stdio', lsp.command_for buffer
uses the mode's servers when lsp_command is blank
buffer.config.lsp_command = ''
assert.equals 'true', lsp.command_for buffer
buffer.config.lsp_enabled = false
assert.is_nil lsp.command_for buffer
buffer.config.lsp_command = 'true'
assert.is_nil lsp.command_for buffer
skips servers that failed to initialize
use_servers { 'true --failing', 'false' }
lsp.on_exit { cmd: 'true --failing', key: 'true --failing@/x', failure: 'bad' }
assert.equals 'false', lsp.command_for buffer
client_for(buffer)
returns nil when no lsp_command is set
assert.is_nil lsp.client_for buffer
returns nil when the buffer has no file
buffer.config.lsp_command = 'true'
assert.is_nil lsp.client_for buffer
returns nil for a file outside of any project
with_tmpdir (dir) ->
file = dir / 'x.py'
file.contents = ''
buffer.file = file
buffer.config.lsp_command = 'true'
assert.is_nil lsp.client_for buffer
returns the client of an attached buffer
buffer.data.lsp = state
assert.equals client, lsp.client_for buffer
starts no server while the project's environment is being found
with_tmpdir (dir) ->
Project.add_root dir
file = dir / 'x.py'
file.contents = ''
buffer.file = file
buffer.config.lsp_command = 'true --lsp-env-test'
handle = dispatch.park 'lsp-env-test'
Project.register_environment_provider name: 'lsp-test', handler: ->
dispatch.wait handle
{ A: '1' }
status, err = pcall ->
assert.is_nil lsp.client_for buffer
assert.is_nil lsp._clients["true --lsp-env-test@#{dir.path}"]
dispatch.resume handle
assert.same { A: '1' }, Project.for_file(file).environment
Project.unregister_environment_provider 'lsp-test'
Project.remove_root dir
Project.open[dir] = nil
error err unless status
on_exit(client)
detaches the buffers of client, and attaches them again on their next edit
app_buffer = howl.app\new_buffer!
client.key = 'exit-test'
app_buffer.data.lsp = state
lsp.on_exit client
assert.is_nil app_buffer.data.lsp
assert.is_true app_buffer.data.lsp_reattach
app_buffer\append 'x'
assert.is_nil app_buffer.data.lsp_reattach
howl.app\close_buffer app_buffer, true
project environment changes
stops the clients of the project, to be restarted
root = File '/tmp/proj'
restarted = root: root, stop: spy.new ->
other = root: File('/tmp/other'), stop: spy.new ->
lsp._clients['env-test@/tmp/proj'] = restarted
lsp._clients['env-test@/tmp/other'] = other
signal.emit 'project-environment-changed', project: { :root }
lsp._clients['env-test@/tmp/proj'] = nil
lsp._clients['env-test@/tmp/other'] = nil
assert.spy(restarted.stop).was_called!
assert.is_true restarted.restarting
assert.spy(other.stop).was_not_called!
assert.is_nil other.restarting
configuration changes
local app_buffer
before_each ->
app_buffer = howl.app\new_buffer!
client.cmd = 'howl-lsp-test'
app_buffer.config.lsp_command = 'howl-lsp-test'
app_buffer.data.lsp = state
after_each ->
app_buffer.data.lsp = nil
howl.app\close_buffer app_buffer, true
keeps buffers attached to a server they should still use
lsp.refresh!
assert.equals state, app_buffer.data.lsp
detaches buffers when lsp_enabled is set to false
app_buffer.config.lsp_enabled = false
assert.is_nil app_buffer.data.lsp
detaches buffers when lsp_command changes to another server
app_buffer.config.lsp_command = 'howl-lsp-other'
assert.is_nil app_buffer.data.lsp
attaching
local dir, app_buffer, running
before_each ->
dir = File.tmpdir!
Project.add_root dir
file = dir / 'x.py'
file.contents = 'x = 1\n'
app_buffer = howl.app\new_buffer!
app_buffer.file = file
app_buffer.config.lsp_command = 'howl-lsp-test'
running = cmd: 'howl-lsp-test', notify: spy.new(->), completion_triggers: {}
lsp._clients["howl-lsp-test@#{dir.path}"] = running
after_each ->
lsp.detach app_buffer
howl.app\close_buffer app_buffer, true
lsp._clients["howl-lsp-test@#{dir.path}"] = nil
Project.remove_root dir
Project.open[dir] = nil
dir\delete_all!
does not attach a buffer when its file is opened
signal.emit 'file-opened', file: app_buffer.file, buffer: app_buffer
assert.is_nil app_buffer.data.lsp
attaches a buffer when an editor shows it
editor = Editor Buffer {}
editor.buffer = app_buffer
editor\release!
attached = app_buffer.data.lsp
assert.equals running, attached.client
assert.spy(running.notify).was_called_with running, 'textDocument/didOpen', {
textDocument: { uri: attached.uri, languageId: 'default', version: 1, text: 'x = 1\n' }
}
attaches the showing buffers again when a restarting client exits, but not otherwise
editor = Editor Buffer {}
editor.buffer = app_buffer
status, err = pcall ->
for restarting in *{ false, true }
exiting = key: 'exiting', stopping: true, :restarting, notify: spy.new ->
app_buffer.data.lsp.client = exiting
lsp.on_exit exiting
if restarting
assert.equals running, app_buffer.data.lsp.client
else
assert.is_nil app_buffer.data.lsp
lsp.attach app_buffer
editor\release!
error err unless status
does not attach a buffer previewed in an editor
editor = Editor Buffer {}
editor\preview app_buffer
editor\release!
assert.is_nil app_buffer.data.lsp
does not attach a hidden buffer on configuration or mode changes, or saves
lsp.refresh!
assert.is_nil app_buffer.data.lsp
mode.register name: 'lsp-test', create: -> {}
app_buffer.mode = mode.by_name 'lsp-test'
mode.unregister 'lsp-test'
assert.is_nil app_buffer.data.lsp
app_buffer\save!
assert.is_nil app_buffer.data.lsp
stop_idle(now)
local idle, busy
before_each ->
idle = stop: spy.new(->), last_used: 0
busy = stop: spy.new(->), last_used: 100 * 60
lsp._clients.idle = idle
lsp._clients.busy = busy
after_each ->
lsp._clients.idle = nil
lsp._clients.busy = nil
howl.config.lsp_server_idle_stop = nil
stops clients not used for lsp_server_idle_stop minutes
howl.config.lsp_server_idle_stop = 30
lsp.stop_idle 100 * 60 + 1
assert.spy(idle.stop).was_called(1)
assert.spy(busy.stop).was_not_called!
stops nothing when lsp_server_idle_stop is 0
howl.config.lsp_server_idle_stop = 0
lsp.stop_idle 100 * 60 + 1
assert.spy(idle.stop).was_not_called!
sync(buffer)
sent_changes = ->
assert.spy(client.notify).was_called(1)
method, params = client.notify.calls[1].vals[2], client.notify.calls[1].vals[3]
assert.equals 'textDocument/didChange', method
params.contentChanges
range = (l1, c1, l2, c2) ->
{ start: { line: l1, character: c1 }, ['end']: { line: l2, character: c2 } }
apply = (text, changes) ->
doc = Buffer {}
doc.text = text
pos_for = (p) ->
line = doc.lines[p.line + 1]
doc\char_offset line.byte_start_pos + p.character
for c in *changes
start_pos = pos_for c.range.start
doc\delete start_pos, pos_for(c.range['end']) - 1
doc\insert c.text, start_pos
doc.text
sends the full text with a new version when the server lacks incremental sync
buffer.text = 'hello'
buffer.data.lsp = state
lsp.sync buffer
assert.spy(client.notify).was_not_called!
buffer\append ' world'
assert.is_true state.dirty
lsp.sync buffer
assert.spy(client.notify).was_called_with client, 'textDocument/didChange', {
textDocument: { uri: 'file:///tmp/x.py', version: 2 },
contentChanges: { { text: 'hello world' } }
}
assert.is_false state.dirty
lsp.sync buffer
assert.spy(client.notify).was_called(1)
sends the full text when the sync kind is given as a number
client.capabilities = textDocumentSync: 1
buffer.text = 'hello'
buffer.data.lsp = state
buffer\append '!'
lsp.sync buffer
assert.same { { text: 'hello!' } }, sent_changes!
(when the server supports incremental sync)
before_each ->
client.capabilities = textDocumentSync: { change: 2 }
buffer.text = 'åäö\nxÅy'
buffer.data.lsp = state
sends inserts as empty ranges with utf-8 byte columns
buffer\insert 'Z', 3
lsp.sync buffer
assert.same { { range: range(0, 4, 0, 4), text: 'Z' } }, sent_changes!
sends deletes as ranges with empty text
buffer\delete 6, 7
lsp.sync buffer
assert.same { { range: range(1, 1, 1, 4), text: '' } }, sent_changes!
sends replacements as a single change
buffer\change 2, 3, ->
buffer\delete 2, 3
buffer\insert 'bc', 2
lsp.sync buffer
assert.same { { range: range(0, 2, 0, 6), text: 'bc' } }, sent_changes!
handles changes spanning lines
buffer\delete 3, 6
buffer\insert '1\n22\r\n3', 3
lsp.sync buffer
assert.same {
{ range: range(0, 4, 1, 3), text: '' },
{ range: range(0, 4, 0, 4), text: '1\n22\r\n3' }
}, sent_changes!
sends all pending changes in order, once
old_text = buffer.text
buffer\insert 'a\nb', 1
buffer\delete 4, 6
buffer\append '\nend'
buffer\change 1, 2, ->
buffer\delete 1, 2
buffer\insert 'X', 1
buffer\undo!
lsp.sync buffer
assert.equals buffer.text, apply(old_text, sent_changes!)
lsp.sync buffer
assert.spy(client.notify).was_called(1)
sends the full text when there are too many pending changes
buffer\append 'x' for _ = 1, 101
lsp.sync buffer
assert.same { { text: buffer.text } }, sent_changes!
assert.same {}, state.changes
sends the full text when an edit splits a "\\r\\n" pair
buffer.text = 'a\rb'
lsp.sync buffer
client.notify\clear!
buffer\insert '\n', 3
lsp.sync buffer
assert.same { { text: 'a\r\nb' } }, sent_changes!
detach(buffer)
sends didClose and forgets the buffer
buffer.data.lsp = state
lsp.detach buffer
assert.spy(client.notify).was_called_with client, 'textDocument/didClose', {
textDocument: { uri: 'file:///tmp/x.py' }
}
assert.is_nil buffer.data.lsp
clears the diagnostics for the buffer
buffer.text = 'hello'
buffer.markers\add {
{ name: 'inspection', source: 'lsp', start_offset: 1, end_offset: 3 }
}
buffer.data.lsp = state
lsp.detach buffer
assert.same {}, buffer.markers.all
on_diagnostics(client, params)
local params
lsp_markers = -> buffer.markers\find name: 'inspection', source: 'lsp'
before_each ->
buffer = howl.app\new_buffer!
buffer.text = 'hello\nworld'
buffer.data.lsp = state
params = {
uri: state.uri,
diagnostics: {
{
range: { start: { line: 1, character: 1 }, ['end']: { line: 1, character: 3 } },
message: 'oops'
}
}
}
after_each ->
buffer.data.lsp = nil
howl.app\close_buffer buffer, true
marks the diagnostics in the buffer with the matching uri
lsp.on_diagnostics client, params
markers = lsp_markers!
assert.equals 1, #markers
assert.same {8, 10, 'oops', 'error'}, {
markers[1].start_offset, markers[1].end_offset, markers[1].message, markers[1].flair
}
ignores diagnostics for another client or uri
lsp.on_diagnostics {}, params
params.uri = 'file:///tmp/other.py'
lsp.on_diagnostics client, params
assert.same {}, lsp_markers!
ignores diagnostics while the buffer has unsynced changes
state.dirty = true
lsp.on_diagnostics client, params
assert.same {}, lsp_markers!
ignores diagnostics for another version of the document
params.version = 7
lsp.on_diagnostics client, params
assert.same {}, lsp_markers!
params.version = 1
lsp.on_diagnostics client, params
assert.equals 1, #lsp_markers!