LSP Servers in Rust: Why the Protocol and the Type System Fit Each Other
Source: lobsters
The Language Server Protocol has quietly become one of the most important specifications in developer tooling over the past decade. What started as a Microsoft initiative to power VS Code’s language features has become a lingua franca for editor integrations, now supported by nearly every serious editor and IDE. Building an LSP server in Rust turns out to be more approachable than most developers expect. The reason goes beyond good library ergonomics: Rust’s type system maps onto the LSP protocol model with unusual precision.
What LSP Actually Specifies
The protocol itself is JSON-RPC 2.0 over stdio, a TCP socket, or a named pipe. The client (editor) sends requests and notifications; the server responds to requests and can also send notifications or even make requests back to the client. The distinction between requests and notifications matters: requests expect a response, notifications do not. This means the server must handle partial failure carefully, responding to each request it receives, even if only to return an error.
The lifecycle follows a strict sequence. The client sends initialize with its own capabilities; the server responds with its capabilities; then the client sends initialized as a notification to signal readiness. From there, the server processes document synchronization events (textDocument/didOpen, textDocument/didChange, textDocument/didClose) and responds to feature requests like textDocument/hover, textDocument/completion, and textDocument/definition. Shutdown is a two-step process: shutdown (a request that must be acknowledged), followed by exit (a notification).
The capabilities negotiation is the protocol’s most interesting design choice. Rather than specifying a fixed feature set, both client and server declare what they support during initialization. A server that only implements hover does not need to handle completion requests. A client that does not support workspace/applyEdit will not ask for it. This negotiation means a minimal server can be genuinely minimal without protocol violations.
The UTF-16 Trap
One protocol detail that catches every LSP implementer at least once is Position. The LSP 3.17 specification defines positions in terms of UTF-16 code units, not bytes or Unicode code points. This made sense when VS Code was built on a JavaScript engine where strings are natively UTF-16, but it creates real work for servers processing source text in any other encoding.
pub struct Position {
pub line: u32, // 0-based line number
pub character: u32, // 0-based, measured in UTF-16 code units
}
For ASCII-only source files this distinction is irrelevant. For anything involving characters outside the Basic Multilingual Plane, such as certain emoji or CJK extension characters, offsets calculated in bytes or Unicode code points will be wrong. The spec added an optional positionEncoding negotiation in version 3.17 to allow UTF-8 or UTF-32, but client support is inconsistent. Writing conversion utilities early saves debugging time later.
The Rust Ecosystem
Two crates do most of the work.
lsp-types provides Rust struct and enum definitions for every type in the LSP specification. The types use serde derives, so JSON serialization is automatic. The crate tracks the specification closely. Many types use Option<OneOf<A, B>> to represent union types from the spec, which is accurate to the protocol but verbose to work with.
tower-lsp is the higher-level framework. It defines a LanguageServer trait with async methods for every LSP request and notification. You implement the trait for your server struct, and tower-lsp handles dispatch, protocol framing, and lifecycle management. The name reflects its foundation on the tower service abstraction, the same middleware model used by axum and hyper.
A complete server that handles hover requires surprisingly few dependencies: tower-lsp, tokio with the full feature flag, and serde_json for any custom parsing. The implementation looks like this:
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService, Server};
#[derive(Debug)]
struct Backend {
client: Client,
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
Ok(InitializeResult {
capabilities: ServerCapabilities {
hover_provider: Some(HoverProviderCapability::Simple(true)),
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
..Default::default()
},
..Default::default()
})
}
async fn initialized(&self, _: InitializedParams) {
self.client
.log_message(MessageType::INFO, "server initialized!")
.await;
}
async fn shutdown(&self) -> Result<()> {
Ok(())
}
async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let pos = params.text_document_position_params.position;
Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("Position: {}:{}", pos.line, pos.character),
}),
range: None,
}))
}
}
#[tokio::main]
async fn main() {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(|client| Backend { client });
Server::new(stdin, stdout, socket).serve(service).await;
}
The LanguageServer trait provides default implementations for most methods that return a “method not found” error, so you only implement what you need. Unimplemented capabilities simply do not appear in the ServerCapabilities struct you return from initialize, and well-behaved clients will never call them.
Why This Fits Rust Particularly Well
The LSP specification is fundamentally a typed protocol: every request and response has a schema, every union is enumerated, every optional field is marked. Rust’s type system was designed to represent exactly this kind of structure without loss of information.
Consider the alternative in Python with pygls. The framework is well-designed and development is pleasant, but Python’s type system does not enforce protocol correctness at compile time. You can return the wrong response type from a handler, and the error surfaces at runtime during an editor session. In TypeScript with vscode-languageserver-node, the story is better because TypeScript can express discriminated unions, but optional type checking means protocol errors can still slip through.
In Rust, if your hover handler returns a Hover struct with a missing required field, the code does not compile. The serde derives on all lsp-types structs mean the JSON layer is also type-driven; there is no stringly-typed intermediate representation between your logic and the wire format. The Option<T> type maps directly to optional fields in the spec, and exhaustive pattern matching ensures you handle every variant of an LSP enum.
One subtle benefit involves the ServerCapabilities struct, which has roughly fifty optional fields, each declaring a different feature. Using ..Default::default() to fill in the unset fields is idiomatic Rust, and it means your capability declaration is both concise and complete: you cannot accidentally omit a required field or declare a capability without the corresponding type checking.
Lower-Level Control with lsp-server
For servers that need tighter control over their event loop, the lsp-server crate from the rust-analyzer team provides a different interface. Rather than a trait with async methods, it exposes a Connection that gives access to the raw message stream:
use lsp_server::{Connection, Message};
use lsp_types::ServerCapabilities;
fn main() {
let (connection, io_threads) = Connection::stdio();
let server_capabilities =
serde_json::to_value(ServerCapabilities::default()).unwrap();
let _init_params = connection.initialize(server_capabilities).unwrap();
for msg in &connection.receiver {
match msg {
Message::Request(req) => {
if connection.handle_shutdown(&req).unwrap() {
break;
}
// dispatch by req.method
}
Message::Notification(_notif) => {}
Message::Response(_resp) => {}
}
}
io_threads.join().unwrap();
}
rust-analyzer uses this approach internally. The explicit event loop gives full control over request ordering, cancellation handling, and threading, at the cost of writing more dispatch code yourself. For a production server with complex incremental computation, this tradeoff is reasonable. For most language servers, tower-lsp’s abstraction is sufficient and involves significantly less boilerplate.
The two crates reflect different points on a design spectrum that exists in most protocol library ecosystems: high-level frameworks that handle dispatch for you versus low-level transports that expose the raw message flow. The right choice depends on whether your server needs to reason about request ordering or handle cancellation in ways the framework does not anticipate.
Real Servers Worth Studying
Several production-quality LSP servers written in Rust are worth reading as references.
taplo is a TOML language server that uses tower-lsp. Its structure closely mirrors the pattern above, with document state stored behind an Arc<RwLock<...>> shared across async handlers. It is a useful reference for managing mutable document state in a concurrent context without reaching for global locks.
nil is a Nix language server that uses lsp-server and borrows architectural ideas from rust-analyzer, separating the LSP communication layer from the analysis engine using incremental computation. The separation makes the codebase easier to test and reason about than a monolithic approach would allow.
harper is a grammar checker LSP, simpler in scope than a full language server. Its relative simplicity makes it a good entry point for understanding how to structure a server that performs text analysis without a complete language parser underneath.
Where the Work Actually Lives
The ease of the protocol layer does not extend uniformly to everything above it. The hard parts of a language server involve building a semantic model of the language being served: tracking open documents, applying incremental edits correctly, parsing efficiently on every keystroke, resolving symbols across files, and handling states where the document is syntactically invalid mid-edit. No framework provides this.
What Rust offers at that layer is also substantial. The ownership model suits document state shared between concurrent requests. The type system helps ensure that the document store and the analysis engine agree on data representations. The performance characteristics make incremental re-analysis on every edit practical in ways that a garbage-collected runtime would make harder to guarantee consistently.
The protocol handling turns out to be a small fraction of the total work in any serious language server, and it is the fraction where Rust’s type system and async tooling align most precisely with what the job requires. The language model beneath it is where the real engineering lives, and Rust has useful things to say about that too.