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)

pos_for(buffer, position)

before_each -> buffer.text = 'åäö\nxÅy'

returns the buffer position for the zero-based line and utf-8 byte column

assert.equals 1, lsp.pos_for(buffer, { line: 0, character: 0 })
assert.equals 3, lsp.pos_for(buffer, { line: 0, character: 4 })
assert.equals 7, lsp.pos_for(buffer, { line: 1, character: 3 })

returns the end of the line for a character beyond it

assert.equals 4, lsp.pos_for(buffer, { line: 0, character: 99 })

returns the end of the buffer for a line beyond the last one

assert.equals 8, lsp.pos_for(buffer, { line: 2, character: 0 })

server_for(buffer)

local dir, project
servers = {
  { name: 'howl-other', command: 'true --other', modes: 'lsp-other' },
  { name: 'howl-venv', command: 'howl-lsp-venv-server', modes: 'lsp-test' },
  { name: 'howl-missing', command: 'howl-no-such-server --stdio', modes: 'lsp-test' },
  { name: 'howl-true', command: 'true', init_options: { a: 1 }, modes: { 'lsp-other', 'lsp-test' } },
  { name: 'howl-false', command: 'false', modes: 'lsp-test' }
}

fields = (server) -> { server.name, server.command, server.init_options }

fails = (server) ->
  lsp.on_exit { server_id: server.id, key: "#{server.id}@/x", failure: 'bad' }

before_each ->
  mode.register name: 'lsp-test', create: -> {}
  dir = File.tmpdir!
  Project.add_root dir
  buffer.file = dir / 'x.test'
  buffer.mode = mode.by_name 'lsp-test'
  project = Project.for_file buffer.file
  lsp.register_server s for s in *servers

after_each ->
  lsp.unregister_server s.name for s in *servers
  mode.unregister 'lsp-test'
  Project.remove_root dir
  Project.open[dir] = nil
  dir\delete_all!

returns the first registered server for the buffer's mode that is installed

assert.same { 'howl-true', 'true', { a: 1 } }, fields lsp.server_for(buffer)

returns nil when none of the servers for the mode are installed

lsp.unregister_server 'howl-true'
lsp.unregister_server 'howl-false'
assert.is_nil lsp.server_for buffer

returns nil when no servers are registered for the mode

buffer.mode = mode.by_name 'default'
assert.is_nil lsp.server_for buffer

returns nil for a buffer without a file, or with a file outside of any project

other = Buffer mode.by_name('lsp-test')
assert.is_nil lsp.server_for other
with_tmpdir (outside) ->
  other.file = outside / 'x.test'
  assert.is_nil lsp.server_for other

looks for the servers' executables with the project's PATH

bin = dir / 'bin'
bin\mkdir!
(bin / 'howl-lsp-venv-server').contents = ''
assert.is_nil howl.sys.find_executable 'howl-lsp-venv-server'
project.environment = PATH: "#{bin.path}:#{howl.sys.env.PATH}"
assert.equals 'howl-venv', lsp.server_for(buffer).name

returns nil while the project's environment is being found

handle = dispatch.park 'lsp-server-env-test'
Project.register_environment_provider name: 'lsp-test', handler: ->
  dispatch.wait handle
  {}

status, err = pcall ->
  assert.is_nil lsp.server_for buffer
  dispatch.resume handle
  assert.equals 'howl-true', lsp.server_for(buffer).name

Project.unregister_environment_provider 'lsp-test'
error err unless status

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

buffer.config.lsp_enabled = false
assert.is_nil lsp.server_for buffer
buffer.config.lsp_server = 'howl-true'
assert.is_nil lsp.server_for buffer

skips servers that failed to initialize

lsp.register_server name: 'howl-true', command: 'true --failing', modes: 'lsp-test'
fails lsp.server_for(buffer)
assert.equals 'howl-false', lsp.server_for(buffer).name

does not skip a failed server once its init options change

buffer.config.lsp_server = name: 'howl-true', init_options: { failing: true }
fails lsp.server_for(buffer)
assert.is_nil lsp.server_for buffer
buffer.config.lsp_server = name: 'howl-true', init_options: { failing: false }
assert.is_not_nil lsp.server_for buffer

gives the same id to equal definitions, and another to different ones

id_of = (server) ->
  buffer.config.lsp_server = server
  lsp.server_for(buffer).id

id = id_of name: 'x', command: 'x', init_options: { a: 1, b: { 2, 3 } }
assert.equals id, id_of name: 'x', command: 'x', init_options: { b: { 2, 3 }, a: 1 }
assert.not_equals id, id_of name: 'x', command: 'x', init_options: { a: 1, b: { 3, 2 } }
assert.not_equals id, id_of name: 'x', command: 'x'
assert.not_equals id, id_of name: 'x', command: 'x --other', init_options: { a: 1, b: { 2, 3 } }
assert.not_equals id, id_of name: 'y', command: 'x', init_options: { a: 1, b: { 2, 3 } }

(with a configured lsp_server)

returns the registered server of that name, even when not installed or for another mode

buffer.config.lsp_server = 'howl-missing'
assert.same { 'howl-missing', 'howl-no-such-server --stdio' }, fields lsp.server_for(buffer)
buffer.config.lsp_server = 'howl-other'
assert.same { 'howl-other', 'true --other' }, fields lsp.server_for(buffer)

returns nil and warns once for a name that is not registered

buffer.config.lsp_server = 'howl-unknown-server'
warn = spy.on log, 'warn'
status, err = pcall ->
  assert.is_nil lsp.server_for buffer
  assert.is_nil lsp.server_for buffer
log.warn\revert!
error err unless status
assert.spy(warn).was_called(1)
assert.includes warn.calls[1].vals[1], "'howl-unknown-server'"

fills in a definition from the registered server of the same name

buffer.config.lsp_server = name: 'howl-true', init_options: { b: 2 }
assert.same { 'howl-true', 'true', { b: 2 } }, fields lsp.server_for(buffer)

buffer.config.lsp_server = name: 'howl-true', command: 'true --changed'
assert.same { 'howl-true', 'true --changed', { a: 1 } }, fields lsp.server_for(buffer)

returns a definition for a server that is not registered as is

buffer.config.lsp_server = name: 'other', command: 'howl-other-server', init_options: { c: 3 }
assert.same { 'other', 'howl-other-server', { c: 3 } }, fields lsp.server_for(buffer)

returns nil for a definition with no command of its own or from the registry

buffer.config.lsp_server = name: 'howl-no-command', init_options: { c: 3 }
warn = spy.on log, 'warn'
status, err = pcall -> assert.is_nil lsp.server_for buffer
log.warn\revert!
error err unless status
assert.spy(warn).was_called(1)

uses the mode's servers when lsp_server is blank

buffer.config.lsp_server = ''
assert.equals 'howl-true', lsp.server_for(buffer).name

can only be a name or a definition with a name

assert.raises 'lsp_server', -> buffer.config.lsp_server = { command: 'true' }
assert.raises 'lsp_server', -> buffer.config.lsp_server = 1

register_server(server)

requires a name, a command and modes

assert.raises 'name', -> lsp.register_server command: 'x', modes: 'm'
assert.raises 'command', -> lsp.register_server name: 'x', modes: 'm'
assert.raises 'modes', -> lsp.register_server name: 'x', command: 'x'

replaces a server registered with the same name, keeping its place

mode.register name: 'lsp-test', create: -> {}
buffer.mode = mode.by_name 'lsp-test'
with_tmpdir (dir) ->
  Project.add_root dir
  buffer.file = dir / 'x.test'
  lsp.register_server name: 'howl-first', command: 'true', modes: 'lsp-test'
  lsp.register_server name: 'howl-second', command: 'true --second', modes: 'lsp-test'
  status, err = pcall ->
    lsp.register_server name: 'howl-first', command: 'true --replaced', modes: 'lsp-test'
    assert.equals 'true --replaced', lsp.server_for(buffer).command

  lsp.unregister_server 'howl-first'
  lsp.unregister_server 'howl-second'
  Project.remove_root dir
  Project.open[dir] = nil
  mode.unregister 'lsp-test'
  error err unless status

client_for(buffer)

returns nil when there is no server for the buffer

assert.is_nil lsp.client_for buffer

returns nil when the buffer has no file

buffer.config.lsp_server = name: 'true', 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_server = name: 'true', 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_server = name: 'env-test', 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["#{lsp.server_for(buffer).id}@#{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
server = name: 'howl-lsp-test', command: 'howl-lsp-test'

before_each ->
  app_buffer = howl.app\new_buffer!
  app_buffer.text = 'x'
  app_buffer.config.lsp_server = server
  client.server_id = lsp.server_for(app_buffer).id
  client.stop = spy.new ->
  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
assert.spy(client.stop).was_not_called!

detaches buffers when lsp_enabled is set to false, stopping the server

app_buffer.config.lsp_enabled = false
assert.is_nil app_buffer.data.lsp
assert.spy(client.stop).was_called(1)

detaches buffers when lsp_server changes to another server, stopping the old one

app_buffer.config.lsp_server = name: 'howl-lsp-other', command: 'howl-lsp-other'
assert.is_nil app_buffer.data.lsp
assert.spy(client.stop).was_called(1)

detaches buffers when the init options change, stopping the old server

app_buffer.config.lsp_server = name: 'howl-lsp-test', command: 'howl-lsp-test', init_options: { a: 1 }
assert.is_nil app_buffer.data.lsp
assert.spy(client.stop).was_called(1)

detaches buffers from a server that is unregistered, stopping it

lsp.register_server name: 'howl-lsp-test', command: 'howl-lsp-test', modes: 'lsp-none'
app_buffer.config.lsp_server = 'howl-lsp-test'
assert.equals state, app_buffer.data.lsp
lsp.unregister_server 'howl-lsp-test'
assert.is_nil app_buffer.data.lsp
assert.spy(client.stop).was_called(1)

does not stop a server that another buffer still uses

other = howl.app\new_buffer!
other.config.lsp_server = server
other.data.lsp = { :client, uri: 'file:///tmp/y.py', version: 1, dirty: false, changes: {}, full: false }
status, err = pcall ->
  app_buffer.config.lsp_server = name: 'howl-lsp-test', command: 'howl-lsp-test', init_options: { a: 1 }
  assert.is_nil app_buffer.data.lsp
  assert.equals client, other.data.lsp.client
  assert.spy(client.stop).was_not_called!

other.data.lsp = nil
howl.app\close_buffer other, true
error err unless status

attaching

local dir, app_buffer, running, key

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_server = name: 'howl-lsp-test', command: 'howl-lsp-test'
  server_id = lsp.server_for(app_buffer).id
  running = :server_id, notify: spy.new(->), completion_triggers: {}
  key = "#{server_id}@#{dir.path}"
  lsp._clients[key] = running

after_each ->
  lsp.detach app_buffer
  howl.app\close_buffer app_buffer, true
  lsp._clients[key] = 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!