Why building a Rust LSP is hard
The Complexity of Crafting a Rust LSP
Once upon a time, the legendary matklad provided an abundance of insights into the inner workings of Rust tooling. While those days were golden, the rust-analyzer blog has been silent since 2023. While I don't claim to be matklad, I have spent considerable time developing Rust Glancer—an experimental Rust Language Server Protocol (LSP) implementation.
This project has been one of my most ambitious and intellectually stimulating endeavors. I want to document my findings regarding how Rust LSPs function, drawing comparisons between rust-analyzer and Rust Glancer. This is a narrative about the deceptive nature of LSP development: where "easy" tasks are actually difficult, "hard" tasks are nearly impossible, and some components exist that I never even anticipated.
Disclaimer: I am not an LSP expert. This post is intended to spark curiosity about the internals and pitfalls of language servers rather than serve as a formal, definitive design specification.
To keep the focus on high-level architecture, I have avoided overly dense compiler jargon and used approximate language. For those seeking absolute precision, I have included links to more detailed sources. Additionally, while I have studied the rust-analyzer codebase extensively, I am not a maintainer; please forgive any inaccuracies.
The Dual Nature of the Server
An LSP server is essentially split into two distinct halves:
- The Protocol Layer: The interface that handles the "plumbing"—receiving requests and returning well-formed responses.
- The State Layer: The "brains"—the indexed representation of the code that allows the server to actually answer those requests.
The Initialization Sequence
The process begins with a handshake. The client sends an initialize request, and the server must set itself up.
- Receive
initializerequest - Configure server capabilities
- Receive
initializednotification - Begin actual analysis
At this stage, the server is blind; it only learns which codebase it is dealing with upon receiving these requests. To be useful, the server must then perform indexing™.
The Indexing Dilemma
Indexing is a massive undertaking. If a server insists on completing a full index before responding, users are left staring at a useless editor for seconds:
This highlights a fundamental divergence between a compiler and an LSP:
| Feature | Compiler | LSP |
|---|---|---|
| Definition of "Done" | Binary: The artifact is either built or it isn't. | Incremental: Any useful answer is better than none. |
| User Experience | Waiting for 715/716 crates is frustrating. | Providing partial results immediately is expected. |
| Goal | Produce a final executable. | Provide real-time feedback from partial info. |
To avoid the "indexing freeze," both rust-analyzer and Rust Glancer prioritize the minimum amount of work required to start responding. rust-analyzer triggers workspace discovery after the initial response, whereas Rust Glancer remains dormant until the first actual query arrives.
The Chaos of the Filesystem
Once the handshake is complete, the server is hit with a barrage of queries, such as:
textDocument/didOpentextDocument/inlayHinttextDocument/documentSymboltextDocument/didChange(if the user is typing quickly)
The VFS Solution
The LSP protocol intentionally abstracts the filesystem because documents are often unsaved. However, analyzing a single file in a vacuum is rarely helpful in Rust. This creates a conflict: the LSP is the "source of truth," yet it must still synchronize with the actual disk.
"If you naively mix filesystem access with LSP notifications, you are essentially building a giant, perpetual race condition."
To solve this, we implement a Virtual File System (VFS) and the concept of Source Generations.
Source Generation A unique identifier representing the state of the code at the moment a specific request was made.
By loading sources into memory (the VFS) and tracking generations, we ensure internal consistency. If the state changes, we can simply cancel any in-flight queries that are now based on an obsolete generation.
Concurrency and Execution
Not all queries are created equal. A hover request is computationally cheap, but finding all references for a symbol is an expensive operation. Consequently, processing queries sequentially is impossible; read-only queries must run in parallel.
The architecture requires a loop that can distinguish between mutating and non-mutating operations.
// Conceptual LSP Request Loop
loop {
let request = receiver.recv().await;
if request.is_mutating() {
// Update VFS, increment source generation
apply_changes(request);
cancel_outdated_queries();
} else {
// Spawn parallel read task
tokio::spawn(async move {
process_read_query(request).await;
});
}
}
The Workflow Logic
Finally, a word of caution: if you choose to use async for this logic, be wary of the tendency to accidentally serialize your incoming message stream, which defeats the purpose of parallelism.