Building an LSP Server in Rust: The Protocol Is Already in the Types
Source: lobsters
The Language Server Protocol solved a real combinatorial problem. Before it existed in 2016, adding IDE features for a new language meant writing editor-specific plugins for every major editor separately. N languages times M editors worth of redundant work. LSP collapsed that to N plus M by defining a standard JSON-RPC interface that any editor client and any language server can share. The spec is now at version 3.17 and covers everything from completions and hover to semantic tokens, inlay hints, and call hierarchies.
What the codeinput.com article on building an LSP server in Rust observes, that it’s surprisingly easy and fun, is accurate but worth unpacking. The ease is not because LSP is simple. The spec is substantial. The ease comes from how Rust’s ecosystem has already encoded that complexity into types, leaving you to implement the interesting parts.
The Wire Protocol
LSP sits on top of JSON-RPC 2.0 over a byte stream, typically stdio. Every message is framed with a simple HTTP-like header:
Content-Length: 142\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"textDocument/hover","params":{...}}
Only Content-Length is required. The JSON body contains one of three shapes: requests (which the server must respond to), responses (the answer to a request), or notifications (fire-and-forget from either side). The lifecycle is strict: initialize then initialized then normal operation then shutdown then exit. Sending capabilities back in the InitializeResult response is how the server tells the client what it supports, and the server should only enable capabilities the client declared it can handle.
You almost certainly never want to implement this framing yourself. Both main Rust crates handle it.
Two Approaches: tower-lsp and lsp-server
The dominant choice for most Rust LSP servers is tower-lsp, currently at version 0.20. It models your server as a tower::Service and gives you a single trait to implement:
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 {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::INCREMENTAL,
)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
completion_provider: Some(CompletionOptions::default()),
..Default::default()
},
..Default::default()
})
}
async fn initialized(&self, _: InitializedParams) {
self.client.log_message(MessageType::INFO, "ready").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!("**{}:{}**", 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;
}
Every method on the trait has a default no-op body. You override what you support and declare the corresponding capability in initialize. The Client handle lets you push server-initiated messages, primarily diagnostics via client.publish_diagnostics(uri, diagnostics, version).await.
The alternative is lsp-server, version 0.7, which is what rust-analyzer uses internally. It is intentionally low-level and synchronous. You get a Connection with two crossbeam channels and write your own dispatch loop:
fn main_loop(connection: &Connection, _params: InitializeParams) {
for msg in &connection.receiver {
match msg {
Message::Request(req) => {
if connection.handle_shutdown(&req).unwrap() { return; }
match req.method.as_str() {
"textDocument/hover" => {
let (id, params): (_, lsp_types::HoverParams) =
req.extract("textDocument/hover").unwrap();
let result = serde_json::to_value(None::<Hover>).unwrap();
connection.sender.send(
Message::Response(Response::new_ok(id, result))
).unwrap();
}
_ => {}
}
}
_ => {}
}
}
}
More boilerplate, full control. For a quick tool or a single-purpose linter, tower-lsp’s trait model saves real time. For a production language server with complex background analysis, the lsp-server dispatch loop gives you a cleaner threading model. rust-analyzer, nil (Nix LSP), and texlab all chose this path.
A third option, async-lsp, takes a middle position: async but low-level, and uniquely supports building LSP clients as well as servers, which matters for test harnesses.
What lsp-types Actually Does
Both tower-lsp and lsp-server depend on lsp-types, version 0.97, which is where most of the spec complexity lives. The crate is a pure data-types library with serde derives throughout. It maps LSP’s TypeScript-style union types to Rust enums:
// The spec says hover contents can be MarkedString | MarkedString[] | MarkupContent
pub enum HoverContents {
Scalar(MarkedString),
Array(Vec<MarkedString>),
Markup(MarkupContent),
}
// diagnostic codes can be number | string
pub enum NumberOrString {
Number(i32),
String(String),
}
Because these are Rust enums, the compiler forces you to handle all cases. You cannot accidentally serialize the wrong variant or miss a branch. The spec’s loose TypeScript unions become exhaustive pattern matches. This is a significant part of why LSP server development in Rust feels structured rather than chaotic.
Position carries line and character as u32, both zero-based. Range wraps two positions. Diagnostic contains a range, optional severity, optional code, a message string, and optional related information for multi-location errors. TextEdit pairs a range with replacement text. WorkspaceEdit composes multiple TextEdit values across multiple files. The types are thorough and match the spec closely.
The Incremental Sync Problem
Declaring TextDocumentSyncKind::INCREMENTAL in your capabilities means the client sends only the changed portions of a document on each edit, not the entire file. This is efficient for large files but requires you to maintain a mutable text buffer and apply patches.
The standard tool for this is ropey, version 1.6, which implements a rope data structure: a B-tree of string chunks that supports O(log n) insert, delete, and random access by line or character offset. The API is straightforward:
use ropey::Rope;
let mut doc = Rope::from_str(&initial_text);
// Apply an incremental change
if let Some(range) = change.range {
let start = lsp_pos_to_rope_char(&doc, range.start);
let end = lsp_pos_to_rope_char(&doc, range.end);
doc.remove(start..end);
doc.insert(start, &change.text);
}
There is a persistent friction point here. The LSP spec historically specifies positions in UTF-16 code units, meaning a character offset in the spec is the number of UTF-16 code units from the start of the line, not Unicode scalar values (Rust char) or bytes. Ropey works in Rust char values. Any non-ASCII content requires explicit conversion between the two representations, which most servers implement manually.
LSP 3.17 added positionEncoding negotiation in InitializeResult. Servers can now advertise support for utf-8 or utf-32 position encoding:
InitializeResult {
capabilities: ServerCapabilities {
position_encoding: Some(PositionEncodingKind::UTF8),
// ...
},
..Default::default()
}
When a client accepts this, position offsets in both directions are byte counts, and ropey’s byte-indexed API maps directly without conversion. New servers should negotiate this from the start.
Where the Real Work Is
The projects using tower-lsp in production reveal how thin the protocol layer actually is. typos-lsp, which wraps the typos spell-checker crate as a language server, is around 300 lines of LSP glue code. The LanguageServer implementation overrides only initialize, initialized, shutdown, did_open, did_change, and did_save. Everything interesting happens inside typos. harper-ls follows the same pattern for grammar checking. taplo, the TOML LSP, adds completions and hover from JSON Schema, but again the LSP layer is small compared to the schema resolution and TOML parsing underneath.
The real complexity in a language server is the analysis: parsing source text, building an index, resolving references, computing types, applying incremental invalidation when files change. None of that has anything to do with JSON-RPC. rust-analyzer’s architecture, with its salsa-based incremental query system, is the example to study for this layer, and it has little to do with LSP itself.
Putting It Together
Building an LSP server in Rust is approachable because the protocol work is genuinely handled. tower-lsp reduces server implementation to overriding async methods on a trait. lsp-types encodes the spec’s union types and optional fields into a type-checked structure that serde handles transparently. ropey manages incremental document state. What remains, the work of actually understanding the language or tool you are serving, is entirely up to you, and that is as it should be.
For a new project, start with tower-lsp = "0.20" and tokio with full features. Declare TextDocumentSyncKind::INCREMENTAL and positionEncoding: utf-8 from the beginning to avoid the UTF-16 conversion overhead later. Add ropey = "1.6" for document state. The protocol side will be running in an afternoon; the interesting problems start after that.