The Language Server Protocol is one of the more quietly consequential standards in software tooling. Before LSP arrived in 2016, adding language intelligence to an editor meant either writing a plugin for every editor separately or accepting that only the most popular editors would support your language. LSP separated the analysis work from the editing work: write one server that speaks the protocol, and every LSP-aware editor, from VS Code to Neovim to Helix to Zed, can consume it.
The protocol runs over JSON-RPC 2.0, typically on stdio. Every message has a Content-Length header prefix, the editor sends requests and notifications, and the server responds. The lifecycle starts with an initialize handshake where the client declares what features it supports and the server responds with a ServerCapabilities object declaring what it can do. The server then receives notifications like textDocument/didOpen and textDocument/didChange, and responds to requests like textDocument/hover and textDocument/completion.
This is where Rust enters naturally. The protocol is stateful (you maintain document mirrors), concurrent (multiple requests can arrive before earlier ones resolve), and has clear message framing (that Content-Length header). These properties align well with what Rust’s type system and async runtime handle without friction. A recent walkthrough at codeinput.com makes the case that starting a server is surprisingly accessible, and that holds up in practice, but the reasons why Rust suits this problem go deeper than the tutorial surface.
tower-lsp and the Trait-Based API
The dominant Rust library for LSP servers is tower-lsp, currently at version 0.20. It is built on Tokio and the tower service abstraction. The design is simple: implement the LanguageServer trait on your struct, pass it to LspService::new, connect it to Server::new with stdin and stdout, and call .serve(). The entire entry point for a working server is a handful of lines:
#[tokio::main]
async fn main() {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(|client| MyServer { client });
Server::new(stdin, stdout, socket).serve(service).await;
}
The LanguageServer trait has async methods for every LSP feature. You implement only what you need. The initialize method is where you declare your capabilities:
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()
})
}
What you declare here is the contract. If you omit hover_provider, no editor will send textDocument/hover requests, even if you implement the method. The ServerCapabilities struct in lsp-types, the companion crate that provides all LSP data structures, maps directly to the JSON schema in the LSP specification. Reading the spec alongside the Rust types is a reasonably smooth experience because the naming is consistent and the types encode the protocol semantics precisely.
Document State and the Synchronization Model
The part that trips people up first is document state. LSP servers must maintain an in-memory mirror of every open document because the editor does not guarantee the file on disk is current. After each textDocument/didChange notification, the server receives updated text and must store it.
For full sync mode (TextDocumentSyncKind::FULL), every change sends the complete document text. That is the easy path for a first server. A common pattern uses DashMap, a concurrent hash map shared across async handlers via Arc:
#[derive(Debug, Clone)]
struct MyServer {
client: Client,
documents: Arc<DashMap<Url, String>>,
}
#[tower_lsp::async_trait]
impl LanguageServer for MyServer {
async fn did_change(&self, params: DidChangeTextDocumentParams) {
if let Some(change) = params.content_changes.into_iter().last() {
self.documents.insert(params.text_document.uri, change.text);
}
}
async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let uri = ¶ms.text_document_position_params.text_document.uri;
let _text = self.documents.get(uri).map(|d| d.clone());
// analyze text at cursor position...
Ok(None)
}
}
Incremental sync (TextDocumentSyncKind::INCREMENTAL) is significantly more involved. Each change carries a byte range and replacement text, and you must apply patches to the stored document in sequence. Most production Rust LSP servers use a rope data structure rather than a plain String for this, because rope operations are O(log n) while string splicing at arbitrary byte offsets is O(n). The ropey crate is the standard choice here.
Diagnostics Are Pushed, Not Pulled
One of the less obvious parts of the LSP design is that diagnostics, the red squiggles for errors and warnings, are server-initiated. There is no textDocument/getDiagnostics request. After processing a document change, the server calls client.publish_diagnostics() with whatever it found:
self.client.publish_diagnostics(
uri,
vec![Diagnostic {
range: Range {
start: Position { line: 3, character: 0 },
end: Position { line: 3, character: 12 },
},
severity: Some(DiagnosticSeverity::ERROR),
message: "Undefined symbol 'foo'".to_string(),
..Default::default()
}],
None,
).await;
The client handle in tower-lsp is a typed wrapper around server-to-client notifications. Calling it from within any async handler is safe because tower-lsp serializes writes to stdout internally. The push model suits Rust’s async design well: analyze after didChange, push results, yield back to the runtime. No polling loop needed.
Why the Distribution Story Matters
Language servers have a distribution problem that most software avoids. An LSP binary runs as a subprocess of your editor, which means every developer who uses your language server needs it installed. Node.js LSP servers, such as typescript-language-server, require a Node runtime and take 200 to 800 milliseconds to start as the V8 JIT boots and loads modules. Python LSP servers require a Python environment and carry similar startup cost.
A Rust LSP server compiled as a static binary starts in under 10 milliseconds. It distributes via cargo install my-lsp-server, no runtime required. The baseline memory footprint for a tower-lsp server sits around 10 to 15 megabytes; comparable Node.js servers use 60 to 150 megabytes at idle. These gaps are measurable in daily use, particularly when editors restart the server on project switches or when CI environments install and run the server as part of a lint pass.
This is one reason several non-Rust language tools have moved toward Rust for their LSP component. Biome ships a Rust LSP server for JavaScript and TypeScript linting and formatting, despite targeting a JavaScript ecosystem. The performance and distribution advantages outweighed the language mismatch.
Learning from Real Rust LSP Servers
Two codebases worth studying sit at opposite ends of the complexity spectrum.
typos-lsp is a spell-check language server that wraps the typos crate. The entire LSP layer is under 500 lines of Rust. It implements did_open, did_change, and publish_diagnostics in a direct loop with no custom indexing or incremental computation. It is the clearest minimal tower-lsp example available and a good starting point for understanding the structure before adding complexity.
taplo is a TOML language server supporting hover docs, completion, validation against JSON Schema, and formatting. It uses tower-lsp and demonstrates how to structure a more complete server: a document store with incremental updates, a schema registry, and an asynchronous validation pipeline. It also compiles to WebAssembly, which is a secondary benefit of Rust’s portability story.
rust-analyzer itself uses a different base library, lsp-server (the sync, low-level crate published by the rust-analyzer team), combined with the Salsa incremental computation framework. That architecture is substantially more complex and designed for the demands of a full Rust compiler frontend, but it illustrates that Rust has room for both simple and industrial-scale LSP implementations within the same ecosystem.
The Parts That Remain Genuinely Hard
Building a basic LSP server with tower-lsp is approachable. Building a good one requires working through problems that have nothing to do with the framework.
Symbol resolution, go-to-definition, and find-references require building and maintaining an index of your language’s symbol table as documents change. For many languages, that means parsing (a tree-sitter grammar covers a broad set of languages), scope analysis, and either full re-indexing on each change or incremental invalidation. The LSP framework handles none of this; it provides transport and message routing, not analysis.
Semantic tokens, the feature where the server pushes full-file syntax highlighting data rather than relying on TextMate grammar rules, require a token type mapping that server and client negotiate in initialize. The client lists what token types it supports, the server declares what it will emit, and getting that mapping correct across different editors takes care.
Request cancellation is handled automatically by tower-lsp when the client sends $/cancelRequest, but only if your handler is genuinely async and yields back to the runtime regularly. A handler that does synchronous analysis for several hundred milliseconds will not respond to cancellation until it completes. This pushes you toward structuring analysis work as a series of awaitable steps rather than a single synchronous block.
The LSP specification at version 3.17 is large. The core features, hover, completion, diagnostics, and go-to-definition, cover the majority of daily use, but the full surface includes workspace folders, code actions, document links, inlay hints, call hierarchies, and more. Each addition follows the same pattern: declare the capability, implement the method. But the feature surface expands considerably as a server matures.
For language authors or tool developers who want their work available across every major editor without shipping a runtime dependency, Rust and tower-lsp represent a well-established path. The framework handles the protocol plumbing, the type system encodes the LSP contracts at compile time, and the resulting binary distributes cleanly. The interesting engineering begins once that scaffolding is in place and the actual analysis problem is all that remains.