How it is built
Architecture overview
A language server turns a programming language into editor features (completion, go to definition, rename, diagnostics) behind one protocol, so one server serves every editor. The hard part is not the protocol; it is answering questions about code that is broken half the time (the user is mid-keystroke), while the answer to the previous question is already stale. The design that survives this has four layers. A transport layer frames JSON messages and keeps a model of the open documents. A syntax layer parses every file into a tree that never fails and keeps all text. A semantic layer answers questions (what does this name refer to, what is the type here) lazily and caches the answers in a dependency graph, so an edit invalidates only what it touches. An index layer holds cross-file facts (all symbols, all references) for workspace-wide questions. A scheduler on top decides which work runs now and which is cancelled by the next edit. The rust-analyzer architecture document is the running example; clangd is the contrasting one.
Parts:
- Protocol and transport — JSON-RPC messages over a pipe, positions in the negotiated unit — Language server protocol,
json-rpc(no page yet), Length-prefix framing, Text position encoding - Document and file store — open buffers, on-disk files, change detection — Rope, File system watching
- Syntax — per-file lossless tree, error tolerant, reparsed on edit — Incremental parsing, Recursive-descent parsing,
lossless-syntax-trees(no page yet), Arena allocator,string-interning(no page yet) - Semantic engine — name resolution and types, computed on demand and memoised — Name resolution,
type-inference-hindley-milner(no page yet),incremental-computation(no page yet) - Index and search — workspace-wide symbols and references, fuzzy lookup — Code symbol indexing,
fuzzy-finder-scoring(no page yet) - Scheduler — main thread, worker pool, cancellation, progress — (control logic, no block of its own)
editor ──stdio──► [framing + JSON-RPC] ──► [scheduler / main loop] ──► worker pool
▲ │ state-changing events │ read-only requests
didOpen/didChange ──┘ ▼ ▼
┌─────────────────┐ snapshot ┌──────────────────┐
watcher / editor ──file events─────► │ document store │────────────► │ semantic engine │
│ (VFS + ropes) │ │ (memoised graph) │
└───────┬─────────┘ └───────┬──────────┘
│ text → tree │ symbols, refs
▼ ▼
[syntax: lossless tree] [index: symbol → locations]
How the parts fit together
Protocol and transport
Language server protocol is JSON-RPC 2.0 (json-rpc, no page yet: requests with ids,
notifications without, errors with codes) over a byte stream, each message preceded by headers with a Content-Length
(source: LSP 3.17 specification). That is length-prefix framing: the receiver reads the
header, then exactly that many bytes, with no escaping and no scan for a delimiter. The choice follows from what
travels: JSON bodies contain every byte value including newlines, so a delimiter scheme would need escaping.
Two details of the protocol shape the rest of the server. First, positions are (line, character) pairs, and the
unit of "character" is negotiated. UTF-16 code units is the default, and since 3.17 a client can offer UTF-8 or UTF-32
instead via positionEncoding (source: LSP 3.17). A server whose own text is UTF-8 stores byte offsets and converts
at the edge: text position encoding describes that method (a line-start table plus binary
search, counting columns in the peer's unit). Doing it at the boundary, once, keeps every internal data structure in
a single unit. rust-analyzer states the same rule for its layers: only the top crate knows about LSP and JSON, and the
ide API is built of plain data types such as offsets and ranges (source: rust-analyzer architecture doc).
Second, requests can be cancelled. A client sends $/cancelRequest; the server must still answer a cancelled
request, with an error such as ServerCancelled or ContentModified (codes -32802 and -32801 in 3.17), because every
request id has to be answered (source: LSP 3.17). ContentModified is how a server says "the document changed under
this answer, ask again". Long operations report through $/progress with a token, and results can stream as partial
results.
Document and file store
The server has two sources of truth that disagree: the file on disk and the editor buffer. The protocol makes the
editor authoritative for open documents (didOpen, didChange, didClose) and leaves the rest to the disk. A
virtual file system sits between them: rust-analyzer's vfs crate hands out an opaque FileId for each path and
stores the current contents, so higher layers never see paths and never read the disk (source: rust-analyzer
architecture doc). The buffer representation matters when files are large and edits are frequent. A rope, a
balanced tree of small chunks with cached lengths, gives O(log n) insert and delete and cheap snapshots, which is why
editors favour it; a language server that receives incremental changes (range plus new text) applies them to its copy.
Many servers simply store each file as one string and replace it, which is fine for source files of ordinary size; I
did not verify which representation rust-analyzer's VFS uses and do not claim one.
For closed files, change detection uses file system watching, treating notifications as hints
that may be lost. The protocol allows the client to do the watching: the server registers glob patterns, and the
client sends workspace/didChangeWatchedFiles (source: LSP 3.17). rust-analyzer's own vfs-notify crate is described
in its source as untested and "quite probably buggy", and by default the server relies on the editor's watching
instead (source: rust-analyzer architecture doc). Both choices follow the same rule: do not trust one event channel
and rescan when in doubt.
Syntax
Each file gets its own tree, built by a hand-written recursive-descent parser.
rust-analyzer's parser emits a flat stream of events that a separate step turns into a tree, and it is designed never
to fail: on an unexpected token it records an error and keeps going, so an unfinished foo. still gives a usable
tree around the cursor (source: rust-analyzer architecture doc). The tree is lossless (lossless-syntax-trees, no
page yet): whitespace and comments are kept as tokens, so the tree can print back exactly the source text and every
node has a text range. A rust-analyzer detail worth noting is that the syntax tree crate is value-typed and
per-file: it knows nothing about the rest of the project, which makes it trivial to cache and share between threads
(same document).
Rebuilding the tree on every keystroke is usually fast enough for ordinary files, but incremental parsing
reparses only the damaged region and reuses the other subtrees, O(edit) instead of O(file). tree-sitter takes this
approach as a core feature, with the cost of a more complex parser generator and error recovery model than a
hand-written parser. Interned identifiers (string-interning, no page yet) turn name comparisons into integer
comparisons; rust-analyzer's intern crate does this with shared pointers. An arena allocator
suits syntax and semantic tables with a shared lifetime, where one reset frees a whole generation.
Semantic engine
The semantic model answers "what is this name", "what is the type of this expression", "which impl applies". It is
computed from the trees and the project structure, which rust-analyzer represents as a CrateGraph: crates with
their dependencies, configuration and root files, the one input the server gets from the build system rather than
from source (source: rust-analyzer architecture doc). Everything below that graph is derived.
Name resolution binds each use to a declaration, handling scopes, shadowing and imports. Type
inference (type-inference-hindley-milner, no page yet; Rust's inference is a related unification-based design, not
Algorithm W itself) follows. Both are queries: a function from keys to values that may call other queries. The
engine behind this is incremental-computation (no page yet) and the rust-analyzer implementation is the salsa
library. Its documented algorithm is red-green: each query result is stamped with the revision at which it last
changed; when an input changes, a dependent query is re-examined, its inputs are checked, and if none actually changed
its old result is kept ("backdating"), so the recomputation stops where results stop changing (source: salsa docs).
Durability levels let inputs that rarely change (library crates) skip checks entirely.
A design trick matters more than any algorithm here: make the stable parts of the semantic model independent of the
unstable ones. In rust-analyzer the ItemTree (signatures of items) does not include function bodies, so typing inside
a function body does not invalidate item-level queries such as "which names are in scope in this module" (source:
rust-analyzer architecture doc). Typing in a body re-runs inference for that body only.
Laziness and incrementality are different strategies, and the choice sets the whole design. The rust-analyzer blog compares three architectures: a map-reduce style that indexes stubs per file and combines (fast for languages where declarations can be found without semantic analysis); a snapshot of headers or a preamble rebuilt when it changes (the clangd and C++ approach, forced by the preprocessor and by headers being parsed in the context of the including file); and query-based on-demand computation (rust-analyzer, where Rust's macros and name resolution make declarations depend on far more than syntax) (source: Three Architectures for a Responsive IDE). The qualitative lesson: the more global the dependency from edit to result, the more you need a fine-grained dependency graph.
Macros are the awkward case. Procedural macros run arbitrary code, so rust-analyzer runs them in a separate process
(proc-macro-srv) to isolate crashes (source: rust-analyzer architecture doc).
Index and search
Some questions cannot be answered by walking from the cursor: workspace symbol search, find all references, rename.
They need an index. Code symbol indexing extracts symbols and references per file, gives each
symbol a stable id, and re-indexes only files whose content changed. clangd documents the layered version: a dynamic
index of opened files and their headers (always fresh), a background index built by parsing all project files with a
thread pool and cached on disk as per-file .idx files, an optional static index built offline, and a remote
index for codebases where the full index takes hours and gigabytes of memory; a MergedIndex layers them so
features see one interface (source: clangd, The clangd index). The per-file storage is what makes edits cheap:
replace one file's entry and the rest stay valid.
Fuzzy lookup (fuzzy-finder-scoring, no page yet) ranks names for workspace symbol search and completion. For
rust-analyzer, the symbol index keeps one finite-state-transducer per file from the fst crate and unions them for a
query, so a file update rebuilds one small structure (source: rust-analyzer symbol_index.rs; I read the code, the
architecture doc does not describe it). The scoring that decides whether HshMp matches HashMap is a separate step
on top of the candidate set.
Scheduler
The scheduler is where responsiveness is won or lost. rust-analyzer's main_loop handles requests that change
state or must block typing (such as formatting and on-type edits) on the main thread, and sends other requests to a
thread pool, each working on a consistent snapshot of the database (source: rust-analyzer architecture doc). The
server is stateless in the protocol sense: a request is answered from the current state with no hidden session
state.
Cancellation is the second half. When a new edit arrives, the database revision increments. A running read request
that touches the database notices the revision mismatch and aborts by raising a special Cancelled unwinding panic
which is caught at the ide crate boundary, and the server replies ContentModified (source: rust-analyzer
architecture doc, with the error code from LSP 3.17). Cancellation by exception means analysis code needs no explicit
checks, but code that catches all panics must rethrow this one.
Start-up work, such as priming caches for all crates, runs as background tasks that report $/progress.
Data and control flow
Keystroke to completion list.
- The editor sends
textDocument/didChangewith the changed range; the server updates the buffer in the file store. - The main loop applies the change to the database inputs and bumps the revision. Any running request on the old
revision is cancelled and answered with
ContentModified. - The editor sends
textDocument/completionfor the cursor position. The server converts the (line, character) pair into an internal offset in the negotiated encoding. - A worker takes a snapshot, reparses the file (or its damaged region) into a lossless tree, finds the node at the offset, and queries name resolution and types for the receiver and scope. Only queries whose inputs changed re-run.
- Candidates are ranked and returned; expensive detail (documentation, edits) is deferred to
completionItem/resolveso the first list is quick (source: LSP 3.17).
Cold start.
- The server initialises, negotiates capabilities and position encoding, and receives the workspace folders.
- The build system supplies the project structure (crate graph); the file store loads files and starts watching.
- A background pass parses and indexes the workspace, reporting
$/progress; on-disk caches (as in clangd's.idxfiles) shorten later starts. - Requests arriving meanwhile are answered from what exists, with partial or empty results rather than waiting.
Scaling
In the order the limits appear:
- Per-keystroke latency: parse and recompute only what changed; split item signatures from bodies so bodies do not invalidate the module-level model.
- Whole-workspace start-up: index in the background with a thread pool, cache per-file results on disk, and answer from partial data while it runs.
- Memory: the dependency graph and the index are the big items. Interning and arenas reduce the footprint; a remote index moves it off the laptop (clangd's reported answer for Chromium-sized code).
- Project size in files: watcher limits (inotify descriptors, event overflow) make a naive watcher fail, and the protocol's client-side watching offloads that work.
- Language complexity: macro expansion and templates make the dependency from edit to answer global; that is what pushes designs from the map-reduce style towards query-based computation.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Stale answers after an edit | go to definition lands on the old line | request answered from an older revision | revision check; answer ContentModified and let the client retry |
| Wrong columns | highlights and edits shifted on lines with emoji or CJK | server and client disagree on the position unit | negotiate positionEncoding; convert only at the boundary |
| Cancelled request never answered | editor spinner never ends | protocol requires a response even to a cancelled request | always send an error response |
| Typing lags | completion arrives after the next key | long request on the main thread, or no cancellation | main thread only for state changes; workers snapshot; cancel on revision bump |
| Whole-file invalidation | every keystroke re-resolves the module | function bodies part of the item-level inputs | separate signatures from bodies; backdate unchanged results |
| Lost file events | new file never seen; renamed file shown twice | watcher overflow or unsupported file system | treat events as hints; rescan; let the client watch |
| Parser gives up | features vanish in half-typed code | parser throws on the first error | error-tolerant parser that always returns a tree |
| Macro or build script crash takes server down | server exits when opening one project | untrusted code executed in-process | out-of-process macro server; restart on crash |
| Index never finishes | workspace symbol search empty on big repos | single-threaded indexing or memory pressure | thread pool with disk cache; static or remote index |
| Garbled stream | "invalid header" and a dead session | a log line written to stdout instead of stderr | reserve stdout for protocol; log to stderr |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| rust-analyzer | Rust language server, query-based with salsa | rust-lang/rust-analyzer | MIT or Apache-2.0 | architecture doc is the best written description |
| salsa | incremental computation framework used by rust-analyzer | salsa-rs/salsa | MIT or Apache-2.0 | red-green algorithm, durability |
| rowan | lossless syntax tree library used by rust-analyzer | rust-analyzer/rowan | MIT or Apache-2.0 | README: library for lossless syntax trees |
| clangd | C++ language server in the LLVM project | llvm/llvm-project | Apache-2.0 with LLVM exception | preamble and layered index design |
| gopls | Go language server | golang/tools | BSD-style | lives in the Go tools repository |
| Eclipse JDT LS | Java language server | eclipse-jdtls/eclipse.jdt.ls | EPL-2.0 | built on the Eclipse Java compiler infrastructure |
| tree-sitter | incremental parsing library used by many editors (not a language server by itself) | tree-sitter/tree-sitter | MIT | syntax layer only |
The alternative: no server, a library linked into each editor. This avoids the protocol and the second process but needs one implementation per editor, which is exactly the matrix the protocol exists to remove. A middle path is a syntax-only server on tree-sitter: much cheaper and resilient, with no semantic answers. A compiler-driven server can reuse the real compiler for semantics but must work against code that does not compile.
Sources
- Language Server Protocol 3.17 specification (framing, position encoding, cancellation, progress, watched files).
- rust-analyzer architecture (crate graph, syntax, ItemTree, main loop, cancellation, VFS, proc-macro server).
- Three Architectures for a Responsive IDE, rust-analyzer blog, 2020.
- Salsa algorithm reference (revisions, red-green, backdating).
- clangd design: The clangd index.
- rust-analyzer source: symbol_index.rs, vfs-notify.