logo
Kurashizu Blogwhere ideas flow
  • Home
  • News
  • Blog
  • About
  • Home
  • News
  • Blog
  • About

© 2026 Kurashizu. All rights reserved.

Admin·New Post·Service Status
Powered by Cloudflare·30.10.26
← Back to news

Why building a Rust LSP is hard

Original article
rust-glancer.github.io|122 points|49 comments|by agluszak|Sep 16, 2026

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:

  1. The Protocol Layer: The interface that handles the "plumbing"—receiving requests and returning well-formed responses.
  2. 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 initialize request
  • Configure server capabilities
  • Receive initialized notification
  • 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 TwaitT_{wait}Twait​ seconds: Twait∈[10,100] secondsT_{wait} \in [10, 100] \text{ seconds}Twait​∈[10,100] seconds

This highlights a fundamental divergence between a compiler and an LSP:

FeatureCompilerLSP
Definition of "Done"Binary: The artifact is either built or it isn't.Incremental: Any useful answer is better than none.
User ExperienceWaiting for 715/716 crates is frustrating.Providing partial results immediately is expected.
GoalProduce 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/didOpen
  • textDocument/inlayHint
  • textDocument/documentSymbol
  • textDocument/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 →\rightarrow→ 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.