A Simple Introduction to the Language Server Protocol

Code editors were trash back in the day. (If you compare them to tools nowadays)

Some editors were built for specific languages, and others were more general-purpose but had few excellent features.

Some were expensive, and others were cheap and clunky.

But then, a paradigm shift happened.

VSCode was created, and it was awesome.

But the most notable feature that came a year later was Microsoft's development of the language server protocol (LSP).

πŸ’‘
This is part one of a two-part series. The other two will cover the more practical aspects of developing a language server.

The Protocol That United Them All

Let's first cover what an LSP is, and then I'll tell you why it is very important.

An LSP is the protocol that is used to communicate between a language client and a language server. The language server is used to provide language-specific features such as:

  • Autocomplete.
  • Go to definitions.
  • Advanced syntax highlighting.
  • Find All References.
  • Code formatting.
  • So much more...

The code editor will then take the output of the language server and display it to the user in a nice and intuitive way.

Source

But it wasn't always like that.

In the past, every code editor was responsible for adding such features. They couldn't possibly support every language, so they branded themselves as a code editor for X language.

LSPs fixed this problem by defining a standardized way for code editors to communicate with language servers.

This eased the developers of the code editors as they didn't have to support languages directly. They can implement the LSP protocol as clients and other people can handle the development of the language servers.

An example of a language client would be Visual Studio Code or Neovim.

LSP Specification

The language server protocol is built upon JSON-RPC. It specifically uses JSON RPC 2.0.

A little bit about JSON RPC

Source

You can think of JSON-RPC as a remote procedure call protocol that uses JSON for data encoding.

It was chosen due to some key characteristics that make it suitable for communication between code editors and language servers:

  • Simple and Lightweight – JSON might not be the most lightweight, but it's fairly simple to use and very human-readable.
  • Language Agnosticism – JSON-RPC is not tied to any specific programming language, making it universally applicable.
  • Statelessness and Flexibility – JSON-RPC, being a stateless protocol, allows for flexible communication patterns. Each request from the client to the server contains all the information needed to process the request, making the protocol adaptable to different network environments and use cases.
  • Asynchronous Communication – JSON-RPC supports asynchronous processing, which is essential for LSP. This means the client and server can handle multiple requests and responses concurrently, a necessary feature for responsive and efficient editing experiences in IDEs and text editors.
  • Extensibility – JSON-RPC is extensible, allowing additional data to be included in messages as needed. This capacity for extension is valuable for LSP, as it can evolve to include new features or accommodate specific requirements of different programming languages and tools.
  • Cross-Platform – JSON-RPC's use of standard HTTP and WebSocket for transport makes it compatible across different platforms and network environments. This cross-platform nature aligns with the goals of LSP to be universally applicable.
  • Widespread Support – JSON is a widely used data format, and JSON-RPC is supported by many libraries and tools across various programming languages. This widespread support eases the integration and adoption of LSP in different development tools.

LSP Request/Response Structure

Now that we know more about JSON RPC. Let's take a look at how the LSP request/response structures are.

Request Structure

The interface of a request message looks like this:

interface RequestMessage extends Message {

	/**
	 * The request id.
	 */
	id: integer | string;

	/**
	 * The method to be invoked.
	 */
	method: string;

	/**
	 * The method's params.
	 */
	params?: array | object;
}

Source

There's really nothing special here. This is pretty much the default JSON RPC 2.0 request structure. But let's break it down a bit more:

  • ID – The client sets this field to identify the request uniquely. Once the request is processed, it will return a response with the same request ID so that the client can match which response is for what request.
  • Method – A string containing the name of the method to be invoked.
  • Params (optional) – The parameters to be passed to the method. This can be structured as an array or an object.

The special thing about LSP is the methods that it provides. I'll review them a bit later, but here's an example of a request invoking the textDocument/completion method.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "textDocument/completion",
  "params": {
    "textDocument": {
      "uri": "file:///path/to/file.txt"
    },
    "position": {
      "line": 10,
      "character": 5
    }
  }
}
❓
If you're wondering about the jsonrpc field, don't worry. It simply states the version of JSON RPC. In our case, it will always be 2.0

Response Structure

The interface of a response message looks like this:

interface ResponseMessage extends Message {
	/**
	 * The request id.
	 */
	id: integer | string | null;

	/**
	 * The result of a request. This member is REQUIRED on success.
	 * This member MUST NOT exist if there was an error invoking the method.
	 */
	result?: string | number | boolean | array | object | null;

	/**
	 * The error object in case a request fails.
	 */
	error?: ResponseError;
}

The interface for ResponseError looks like this:

interface ResponseError {
	/**
	 * A number indicating the error type that occurred.
	 */
	code: integer;

	/**
	 * A string providing a short description of the error.
	 */
	message: string;

	/**
	 * A primitive or structured value that contains additional
	 * information about the error. Can be omitted.
	 */
	data?: string | number | boolean | array | object | null;
}

There's nothing special here, either. These are both the standard JSON RPC 2.0 response and error structures.

Here's an example of a response to a request that invoked the method textDocument/completion.

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "isIncomplete": false,
    "items": [
      {
        "label": "print",
        "kind": 3, // Function
        "detail": "print(value: any): void",
        "documentation": "Prints a value to the console."
      },
      {
        "label": "println",
        "kind": 3, // Function
        "detail": "println(value: any): void",
        "documentation": "Prints a value to the console and adds a new line."
      }
      // ... more items ...
    ]
  }
}

Notification Request

A third type of request that does not require a response is called a notification request.

The interface of the notification request looks like this:

interface NotificationMessage extends Message {
	/**
	 * The method to be invoked.
	 */
	method: string;

	/**
	 * The notification's params.
	 */
	params?: array | object;
}

As you can see, it's similar to our base request but does not have an ID as it does not need one.

❓
There are also two other kinds of requests that you can read up on, which are called progress and cancellation requests.

LSP Methods

Example flow between a code editor and a language server communicating using LSP.

So far, we have only discussed the basic structures of a typical JSON RPC request.

The thing that sets LSP apart is the methods that it provides. There are at least sixty distinct methods.

Don't worry, you don't have to learn all of them.

They can be generally broken down into seven categories:

  • General Initialization – Methods for initializing the connection and capabilities exchange between the client and the server.
  • Workspace – Methods and notifications related to workspace functionality, like workspace configuration changes, file events, and workspace folders.
  • Synchronization – Methods for synchronizing text document states between the client and the server, including opening, changing, saving, and closing documents.
  • Diagnostics – Notifications for publishing diagnostics from the server to the client.
  • Language Features – A wide array of methods for language-specific features like code completion, hover information, signature help, references, definitions, document highlights, symbols, formatting, code actions, and more.
  • Progress Reporting and Cancellation – Methods and notifications for reporting the progress of long-running tasks and canceling ongoing operations.
  • Others – Various additional methods and notifications for specific purposes, such as telemetry and window management.
πŸ§‘β€πŸŽ“
If you'd like to learn more about the LSP specification, please check it out here.