Skip to content

About

Language Server Protocol implementation in C++

Topics

Resources

Stars

84 stars

Watchers

4 watching

Forks

Repository files navigation

lsp-framework

This is an implementation of the Language Server Protocol in C++. It can be used to implement both servers and clients that communicate using the LSP.

Overview

The goal of this library is to make implementing LSP servers and clients easy and type safe. All LSP types and messages are proper C++ structs. There's no need to manually read or write JSON, which is inconvenient and error-prone. The framework handles serialization and deserialization automatically.

The generated <lsp/messages.h> header has one struct per message, with requests in the lsp::requests and notifications in the lsp::notifications namespace. Each message struct has a Method constant and typedefs for its Params and Result types. All parameter, result and other LSP types are in <lsp/types.h>.

Servers and clients are implemented using the generated lsp::ServerEndpoint and lsp::ClientEndpoint classes (<lsp/server_endpoint.h> and <lsp/client_endpoint.h>). Request cancelation and trace logging are supported out of the box. The messages.h and types.h headers also serve as a protocol reference and have documentation comments for all types and properties.

Building

There aren't any external dependencies except for cmake and a compiler that supports C++20.

The project is built as a static library. LSP type definitions, messages and serialization boilerplate are generated from the official meta model during the build and are written to <build_dir>/generated/lsp/.

cmake -S . -B build && cmake --build build --parallel

Usage

The two entry points are lsp::ServerEndpoint and lsp::ClientEndpoint. Each is constructed from an lsp::io::Stream and exposes a type-safe method for every message it can send, along with an on... method to register a handler for every message it can receive. The framework provides streams for standard I/O (lsp::io::standardIO()) and TCP sockets (lsp::io::Socket). To communicate over anything else, subclass lsp::io::Stream and implement read and write.

Implementing a Server

Construct an lsp::ServerEndpoint from a stream, register handlers, then run the message loop:

#include <lsp/io/standard_io.h>
#include <lsp/server_endpoint.h>

int main()
{
  auto endpoint = lsp::ServerEndpoint(lsp::io::standardIO());

  endpoint.onInitialize(
    [](lsp::InitializeParams&& params) -> lsp::InitializeResult
    {
      return {
        .capabilities = {
          .hoverProvider = true,
        },
        .serverInfo = lsp::ServerInfo{
          .name = "Example Server",
          .version = "1.0.0"
        },
      };
    })
  .onTextDocumentHover(
    [](lsp::HoverParams&& params) -> lsp::TextDocumentHoverResult
    {
      return lsp::Hover{
        .contents = lsp::MarkupContent{
          .kind  = lsp::MarkupKind::PlainText,
          .value = "Hover at line " + std::to_string(params.position.line),
        },
      };
    });

  endpoint.runMessageLoop();
}

Every message the server can receive has a matching on<MessageName> method, named after the LSP method (e.g., textDocument/hover becomes onTextDocumentHover). Each on... method returns the endpoint, so registrations can be chained.

runMessageLoop() processes messages until the client sends exit or the connection is closed. The message loop can also be implemented manually using processNextMessage() and looping while endpoint.isActive().

The endpoint enforces the protocol lifecycle: requests that arrive before initialize or after shutdown are rejected and exit stops the loop. You can still register onInitialize, onShutdown and onExit to do your own setup and cleanup.

Implementing a Client

A client usually launches the server process itself (see Starting a Server Process) or connects to a running one over a socket (see Using Sockets). Either way it gets a stream to construct an lsp::ClientEndpoint from.

Unlike a server, a client typically runs the message loop on a background thread so the main thread can send requests and wait for their results:

#include <thread>
#include <lsp/client_endpoint.h>
#include <lsp/process.h>

int main(int argc, char** argv)
{
  auto process  = lsp::Process::start("/path/to/server/executable");
  auto endpoint = lsp::ClientEndpoint(process.stdIO());

  // Run the message loop on a separate thread so we can call RequestResult::get on the main thread
  auto messageThread = std::thread([&]{ endpoint.runMessageLoop(); });

  // The first message must be 'initialize', followed by the 'initialized' notification
  auto params = lsp::InitializeParams();
  params.processId = lsp::Process::currentProcessId();
  params.rootUri   = lsp::Uri::fileUriFromPath(".");

  // Call the 'initialize' request. get() waits until the response is returned
  const auto result = endpoint.initialize(params).get();

  // Check server capabilities from the initialize result here...

  // Send the 'initialized' notification to tell the server that the client is ready
  endpoint.initialized({});

  // Send a request and wait for the response
  auto hoverParams = lsp::HoverParams();
  hoverParams.textDocument.uri = lsp::Uri::fileUriFromPath("example.txt");
  hoverParams.position         = {0, 0};
  const auto hover = endpoint.textDocumentHover(hoverParams).get();

  // Do something with the hover result...

  // Shut the server down by sending the 'shutdown' request followed by the 'exit' notification
  (void)endpoint.shutdown().get();
  endpoint.exit();

  messageThread.join();
}

The endpoint throws std::logic_error if any message other than initialize is sent first, or if anything other than exit is sent after shutdown.

Outgoing requests return an lsp::RequestResult. Calling .get() on it blocks until the response arrives, so never do that from the message-loop thread. See Sending Requests and Notifications for the details, and Handling Incoming Messages for reacting to server-to-client messages such as window/logMessage.

Sending Requests and Notifications

Both endpoints have one method per message they can send, named like the on... handlers but without the prefix (textDocument/publishDiagnostics becomes textDocumentPublishDiagnostics). These methods are safe to call from any thread, including while another thread runs the message loop.

Notifications take the parameters (if any) and return nothing:

serverEndpoint.textDocumentPublishDiagnostics({
  .uri         = uri,
  .diagnostics = diagnostics,
});

Requests come in two forms.

The first returns an lsp::RequestResult. Call get() to wait for and retrieve the result. It rethrows an lsp::ResponseError if the server responded with an error. get() blocks, so don't call it on the message-loop thread. RequestResult also has wait(timeoutMs) and requestId(). The request id can be used with $/cancelRequest to cancel longer-running tasks on the server, for example (see Cancelation).

try
{
  const auto result = clientEndpoint.textDocumentHover(params).get();
}
catch(const lsp::ResponseError& e)
{
  // The server responded with an error
}

The second form takes a result callback and an optional error callback and returns the lsp::RequestId of the request. The callbacks run on the message-loop thread once the response arrives, which makes this form safe to use from inside a message handler, where get() would deadlock:

clientEndpoint.textDocumentHover(params,
  [](lsp::TextDocumentHoverResult&& result)
  {
    // Success
  },
  [](const lsp::ResponseError& error)
  {
    // Failure (optional, omit to ignore errors)
  });

Handling Incoming Messages

Every message an endpoint can receive has an on<MessageName> method that registers a handler for it. This is how a client reacts to server-initiated messages, and a server to client requests. Handlers run on the same thread as the message-loop. Registering a handler for a message that already has one replaces it.

A request handler receives the parameters by rvalue reference (omitted when the message has none) and returns the result:

clientEndpoint.onWindowShowMessageRequest(
  [](lsp::ShowMessageRequestParams&& params) -> lsp::WindowShowMessageRequestResult
  {
    if(params.actions && !params.actions->empty())
      return params.actions->front();

    return nullptr;
  });

A notification handler takes the parameters the same way and returns void:

clientEndpoint
  .onWindowLogMessage(
    [](lsp::LogMessageParams&& params)
    {
      std::clog << params.message << '\n';
    })
  .onTextDocumentPublishDiagnostics(
    [](lsp::PublishDiagnosticsParams&& params)
    {
      // Update the diagnostics for params.uri
    });

The parameters are passed by rvalue reference so handlers can move data out of them. This prevents potentially expensive copies such as the full content of a text document. A handler that doesn't need to move anything out can take the parameters by const reference instead.

Returning Errors

To fail a request, throw lsp::RequestError from its handler and the framework turns it into an LSP error response:

#include <lsp/error.h>

serverEndpoint.onTextDocumentHover(
  [](lsp::HoverParams&& params) -> lsp::TextDocumentHoverResult
  {
    if(!documentIsOpen(params.textDocument.uri))
      throw lsp::RequestError(lsp::MessageError::InvalidParams, "Document is not open");

    return /* ... */;
  });

The error code is one of the constants in lsp::MessageError (InvalidParams, InternalError, RequestFailed, RequestCancelled, ...). Any other exception thrown from a handler is sent as an InternalError.

On the sending side the error arrives as an lsp::ResponseError with code(), message() and data(). RequestResult::get() rethrows it, and the callback form passes it to the error callback.

Asynchronous Handlers

Instead of the result, a request handler can return a callable that takes no parameters and produces the result. The handler itself still runs synchronously on the message-loop thread, but the callable is run on a worker thread, so long-running work doesn't block the message loop. The framework only spawns worker threads if at least one asynchronous handler is registered.

serverEndpoint.onTextDocumentDefinition(
  [](lsp::DefinitionParams&& params)
  {
    return [params = std::move(params)]() -> lsp::TextDocumentDefinitionResult
    {
      // Runs on a worker thread
      return computeDefinition(params);
    };
  });

Throwing lsp::RequestError from the callable still produces an error response.

Notification handlers can be asynchronous the same way by returning a no-parameter callable. They can return non-void but the result is discarded.

A request handler can also decide per call whether to run synchronously or asynchronously by declaring its return type as lsp::RequestResult<Result> and returning either a value or a callable. In this form the callable has to be wrapped in an lsp::TaskFunction<Result> explicitly.

serverEndpoint.onTextDocumentDefinition(
  [](lsp::DefinitionParams&& params) -> lsp::RequestResult<lsp::TextDocumentDefinitionResult>
  {
    if(isCached(params))
      return computeDefinition(params); // Response is sent when the handler returns

    return lsp::TaskFunction<lsp::TextDocumentDefinitionResult>(
      // Response is sent once this has finished running on the worker thread
      [params = std::move(params)]() -> lsp::TextDocumentDefinitionResult
      {
        return computeDefinition(params);
      });
  });

Custom Messages

Messages that aren't part of the meta model, such as proprietary $/ extensions, can still be sent and received. Their parameters and results are lsp::json::Value instead of generated structs.

Register handlers with onCustomRequest and onCustomNotification:

serverEndpoint.onCustomRequest("$/myExtension/stat",
  [](lsp::json::Value&& params) -> lsp::json::Value
  {
    auto response = lsp::json::Object();
    response["size"] = 42;
    return response;
  });

serverEndpoint.onCustomNotification("$/myExtension/touch",
  [](lsp::json::Value&& params)
  {
    // ...
  });

Send them with customRequest and customNotification. customRequest takes a result callback and an optional error callback, like the callback form of a generated request, and returns the request's lsp::RequestId:

auto params = lsp::json::Object();
params["path"] = "example.txt";

clientEndpoint.customRequest("$/myExtension/stat", params,
  [](lsp::json::Value&& result)
  {
    // Success
  },
  [](const lsp::ResponseError& error)
  {
    // Failure (optional, omit to ignore errors)
  });

clientEndpoint.customNotification("$/myExtension/touch", params);

For messages without parameters, pass a handler that takes none (onCustomRequest("...", []() -> lsp::json::Value { ... }), onCustomNotification("...", []() { ... })) and use the parameterless send overloads (customRequest(method, then, error), customNotification(method)).

Request Context

Request handlers can access information about the current request through an lsp::RequestContext. It can be obtained using lsp::RequestContext::get().

serverEndpoint.onTextDocumentReferences(
  [](lsp::ReferenceParams&& params) -> lsp::TextDocumentReferencesResult
  {
    const auto& ctx       = lsp::RequestContext::get();
    const auto& requestId = ctx.id();
    // ...
  });

RequestContext::get() throws std::logic_error when there is no active request. Code that runs both inside and outside a handler should use RequestContext::tryGet() instead, which returns a const RequestContext* that is null when there is no context.

The context is set in regular and asynchronous request handler callbacks, including the body of a callable returned by an asynchronous handler while it runs on a worker thread. It is also available in response and error callbacks. It is not set in notification handlers.

Cancelation

ClientEndpoint and ServerEndpoint both handle the $/cancelRequest notification by default: receiving one marks the request id it references as canceled. This alone doesn't stop anything. A handler has to explicitly check for it and decide what to do, typically inside an asynchronous handler doing long-running work on a worker thread. RequestContext::throwIfCanceled() is a shortcut for the common case of aborting with an lsp::RequestError(lsp::MessageError::RequestCancelled, ...):

serverEndpoint.onTextDocumentDefinition(
  [](lsp::DefinitionParams&& params)
  {
    return [params = std::move(params)]() -> lsp::TextDocumentDefinitionResult
    {
      const auto& ctx = lsp::RequestContext::get();

      for(auto& step : longRunningWork(params))
      {
        ctx.throwIfCanceled();
        processStep(step);
      }

      return result;
    };
  });

Throwing isn't the only option. For requests where a partial answer is still useful, check ctx.isCanceled() and return the best result computed so far instead of aborting with an error.

If you register your own handler for $/cancelRequest, it replaces the default one entirely, so cancelation stops working unless you call messageHandler().cancel(id) yourself.

Trace Logging

ServerEndpoint supports LSP trace logging automatically. Once the client enables it, via the trace field of the initialize request or the $/setTrace notification, the server sends $/logTrace notifications describing the messages it processes.

Starting a Server Process

When implementing an LSP client, it is usually responsible for creating the server process. This can be done with the lsp::Process class. Its stdIO() method returns an lsp::io::Stream for the process's standard input and output that an endpoint can be constructed from.

#include <lsp/process.h>
#include <lsp/client_endpoint.h>

auto process  = lsp::Process::start("/usr/bin/clangd", {/*args*/});
auto endpoint = lsp::ClientEndpoint(process.stdIO());

Using Sockets

Sockets are a typical method of communication between language servers and clients, besides stdio. The framework supports connecting to an existing address and port, as well as creating a server and listening for incoming connections.

Clients can use lsp::io::Socket::connect to create a new socket for a given address/port combination and use it to initialize a connection:

#include <lsp/io/socket.h>

unsigned short port = 12345;
auto socket   = lsp::io::Socket::connect(lsp::io::Socket::Localhost, port);
auto endpoint = lsp::ClientEndpoint(socket);

Servers need to listen for incoming socket connections. This is done by creating an lsp::io::SocketListener and calling its accept method in a loop. It waits until a new socket connection is made and returns an lsp::io::Socket. Since multiple connections can be accepted at once, it is possible for a single server executable to communicate with multiple clients. The following example creates a socket server which is listening for incoming connections. If one is made, a new thread is spawned, which uses the socket to create and run a new server instance for that connection:

#include <lsp/io/socket.h>

// 0 means use an automatically assigned port. Get it with SocketListener::port.
auto listener = lsp::io::SocketListener(0);

while(listener.isOpen())
{
  auto socket = listener.accept();

  if(!socket.isOpen())
    break;

  // Start a server instance in a new thread and detach it,
  // so the main thread can keep listening for incoming connections
  std::thread([socket = std::move(socket)]() mutable
  {
    auto endpoint = lsp::ServerEndpoint(socket);
    // Do something with the endpoint...
  }).detach();
}

Examples

Full server and client implementations are in examples/.

The client either launches the server itself and talks to it over stdio, or connects to a running one over a socket:

./example_client --exe=<server_executable> [server args]
./example_client --port=<port>

The server reads from stdin by default, or listens for socket connections with example_server --port=<port>.

License

This project is licensed under the MIT License.

Third-party material included in this repository (the LSP meta model) is covered by a separate license. See THIRD_PARTY_NOTICES.md.

About

Language Server Protocol implementation in C++

Topics

Resources

Stars

84 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages