Language Server Protocol

https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/

Why do we need LSP?

  • Code editor should provide language support
  • Standard approach: a new implementation for each language and editor
  • Better approach: language server communicating with editor using standarized protocol

How it works

What is LSP?

  • Protocol used between editors and servers that provide language support
  • JSON-RPC-based
  • Originally developed for Microsoft Visual Studio Code, now open standard

Key concepts of LSP

Overview

  • Client: editor
  • Server: language server
  • Message exchange through stdio or socket

Exchange of messages

Multiple languages

  • Typical implementation
  • Separate language server for each programming language

Exchange of messages

Capability

  • Set of language features
  • Client and server announce their supported features using capabilities
  • Enables backward compatibility

Messages

Types of messages

  1. Request — expects a response
  2. Response — result of a request
  3. Notification
    • treated as an event
    • must not get a response

Message structure

  • Header
    • Content-Lenght
    • optional Content-Type; default: application/vscode-jsonrpc;charset=utf-8
  • Content
    • jsonrpc – version of JSON-RPC used, always equal to 2.0
    • the rest of the fields depend on the type of the message
Content-Length: ...\r\n
\r\n
{
	"jsonrpc": "2.0",
	"id": 1,
	"method": "textDocument/completion",
	"params": {
		...
	}
}

Request

  • id
  • method
  • optional params
Content-Length: ...\r\n
\r\n
{
	"jsonrpc": "2.0",
	"id": 1,
	"method": "textDocument/completion",
	"params": {
		...
	}
}

Response

  • id
  • optional result
  • optional error
Content-Length: ...\r\n
\r\n
{
	"jsonrpc": "2.0",
	"id": 1,
	"result": {
		...
	}
}

Notification

  • method
  • optional params
Content-Length: ...\r\n
\r\n
{
	"jsonrpc": "2.0",
	"method": "initialized",
	"params": {
		...
	}
}

Server Lifecycle

Phases

  1. Initialization
  2. Communication
  3. Shutdown

Initialization

Exchange of messages

Shutdown

Exchange of messages

Other lifecycle capabilities

  • registering new capabilities
  • setting log preferences

Document synchronization

Mandatory capabilities

Notifications:

  • didOpen – transfers the whole document and locks the file
  • didChange – transfers changes
  • didClose – unlocks the file

Other document synchronization capabilities

  • willing to save the document
  • saving the document

Example

Exchange of messages

A Simple Kotlin Example

(Leveraging kotlin-lsp and LSP4J)
Sources: https://github.com/S-furi/lsp-presentation/tree/main/code/kt-lsp-example

Server Connection

Accomplished by means of Sockets input and output streams

val socket = Socket("127.0.0.1", 9999)

val launcher =
  LSPLauncher.createClientLauncher(
    languageClient,
    socket.inputStream,
    socket.outputStream
  )

launcher.startListening()
val lspServer = launcher.remoteProxy

Registering a Workspace

A Workspace is the location of a Kotlin project. As of today, kotlin-lsp (https://github.com/Kotlin/kotlin-lsp) supports Gradle Kotlin/JVM projects only, meaning that valid workspaces are only kotlin projects with such limitations. Due to this we could not use it with standalone files.

In order to register a workspace, we must specify project’s URI and attach it to the initial request to be sent to the server.

projectFolders = listOf(WorkspaceFolder("file://$projectPath", projectName))
val params = InitializeParams().apply { workspaceFolders = projectFolders }

Initializing the Language Server

Once we successfully launch a client instance, we must start the initialization procedure before any other request. In this phase we specify the WorkspaceFolder and ClientCapabilities (later), upon which the server will respond with his capabilities.

The initialization procedure can end only when, upon receiving server’s response, the client sends an initialized notification in order to signal that it’s ready state.

fun initialize(cc: ClientCapabilities, wf: List<WorkSpaceFolder>): Future<Void> {
  val params = InitializeParams().apply {
    capabilities = cc
    workspaceFolders = wf
  }
  return languageServer.initialize(params).thenCompose {
    languageServer.initialized()
    CompletableFuture.complete(null)
  }
}

Registering Client Capabilities

Client capabilities defines which operations will be available during client-server session. For this project, the most important capability is Completion.

interface CompletionClientCapabilities {
	dynamicRegistration?: boolean;
	completionItem?: {
		snippetSupport?: boolean;
		commitCharactersSupport?: boolean;
		documentationFormat?: MarkupKind[];
		deprecatedSupport?: boolean;
		preselectSupport?: boolean;
		tagSupport?: { valueSet: CompletionItemTag[]; };
		insertReplaceSupport?: boolean;
		resolveSupport?: { properties: string[]; };
		insertTextModeSupport?: { valueSet: InsertTextMode[]; };
		labelDetailsSupport?: boolean;
	};
	completionItemKind?: { valueSet?: CompletionItemKind[]; };
	contextSupport?: boolean;
	insertTextMode?: InsertTextMode;
	completionList?: { itemDefaults?: string[]; }
}

A lot of configurations… mostly related on how the editor shows and insert received completions options.

Registering Client Capabilities (2)

Completion capabilities are under the textDocument set of capabilities, along with others like Hover, Signature, Definition, etc.

val txtDocCap = TextDocumentCapabilities().apply {
  // set completion configurations (could use also have used `apply` here too)
  val cmplCap = CompletionCapabilities()
  cmplCap.setContextSupport(true)
  cmplCap.setCompletionItem(CompletionItemCapabilities(snippetSupport = true))
  // cmplCap.set...

  completion = cmplCap
}
val capabilities = CompletionCapabilities.apply {
  textDocument = txtDocCap
}

Document Synchronization

In order to make the editor signals the language server to:

  • start tracking a file just opened in the editor
  • register changes made by the user
  • stop tracking the opened file

we must use the methods didOpen, didChange and didClose respectively.

To keep track of files and their contents, we use TextDocumentIdentifier: simply a container for their URI and a version number if clients support it (not necessarily incremental).

Document Opening

We create an instance of TextDocumentItem with its URI, version number, language id and it’s content. By sending the notification textDocumentService/didOpen, the server will now start tracking and synching the document.

val content = Files.readString(Paths.get(uri))
val params = DidOpenTextDocumentParams(
    TextDocumentItem(uri.toString(), "kotlin", 1, content)
)
languageServer.textDocumentService.didOpen(params)

Document Changing

Two different approaches in informing the server about changes: incremental, meaning that we send diffs (new contents and their positions), or full where the entire, updated document content is sent to the server. In the latter case, we could simply do:

fun changeDocument(uri: URI, newContent: String) {
    val params = DidChangeTextDocumentParams(
        VersionedTextDocumentIdentifier(uri.toString(), 1),
        listOf(TextDocumentContentChangeEvent(newContent)),
    )
    languageServer.textDocumentService.didChange(params)
}

Changes are represented as a list of TexDocumentContentChangeEvent: such events can define insertions/modifications/deletions in case the strategy for updating the fil is incremental (which must set in initial client capabilities, otherwise full is the default).

Document Closing

The closing notification just simply requires the URI of the document

val params = DidCloseTextDocumentParams(TextDocumentIdentifier(uri.toString()))
languageServer.textDocumentService.didClose(params)

documentText/Completion

Completions can be triggered by 3 main actions inside the editor: 1. By pressing certain characters (e.g. '`.`', '`(`') 2. Explicit invocation (i.e. ` + `) 3. When the current completions is incomplete.

When one of these three events occur, we can trigger a completion specifying the URI of the file and the cursor line and character position, and retrieve a result of type List<CompletionItem> or CompletionList depending on whether current completion is incomplete or not.

fun getCompletion(
    uri: URI,
    position: Position,
    triggerKind: CompletionTriggerKind = CompletionTriggerKind.Invoked
): Future<Completions> {
    val context = CompletionContext(triggerKind)
    val params = CompletionParams(TextDocumentIdentifier(uri.toString()), position, context)
    return languageServer.textDocumentService.completion(params)
        ?: CompletableFuture.completedFuture(Either.forLeft(emptyList()))
}

Recap Scheme

Sources