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

returns a configured lsp_command even when not installed

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

returns nil when lsp_enabled is false, even with a configured lsp_command

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!