Chapter 7: Putting It Together — The Language Server
Wire everything into a language server simulation and watch Salsa skip work on edits.
What You'll Learn
- How the full pipeline fits together: parse → type-check → diagnostics
- How a language server uses Salsa's incrementality on every keystroke
- Per-file isolation in a multi-file project
- The architecture that powers rust-analyzer
The Language Server Loop
A language server does the same thing over and over:
- User opens a file → parse + type check + report diagnostics
- User types a character → update the source → re-parse + re-type-check → report diagnostics
- User saves → same as step 2, but the source is now on disk
The key constraint: step 2 has to be fast. Sub-100ms. The user is typing, and they expect the red squiggles to appear (or disappear) instantly. You can't re-type-check the entire project on every keystroke.
This is exactly the problem Salsa solves. Let's see how.
The LanguageServer Struct
#![allow(unused)] fn main() { struct LanguageServer { db: Database, files: Vec<SourceFile>, } impl LanguageServer { fn open_file(&mut self, path: &str, text: &str) -> usize { let file = SourceFile::new(&self.db, path.to_string(), text.to_string()); let idx = self.files.len(); self.files.push(file); type_check(&self.db, file); // initial check idx } fn edit_file(&mut self, idx: usize, new_text: &str) { self.files[idx].set_text(&mut self.db).to(new_text.to_string()); type_check(&self.db, self.files[idx]); // incremental check } fn diagnostics(&self, idx: usize) -> Vec<Diagnostic> { type_check::accumulated::<Diagnostic>(&self.db, self.files[idx]) .into_iter().cloned().collect() } } }
Three operations: open, edit, get diagnostics. On each edit, we set the new source text and re-type-check. Salsa handles the rest — it only re-runs queries whose inputs actually changed.
The Edit Cycle
User types "x + y" where "x" is Number and "y" is String:
1. set_text() → new revision
2. type_check() → re-runs (source changed)
3. parse() → re-runs (source changed)
4. infer_type() → re-runs for x + y
5. infer_type(x) → cached (x didn't change)
6. infer_type(y) → cached (y didn't change)
7. Diagnostic::error("cannot add Number and String") → accumulated
8. diagnostics() → [Error: "cannot add Number and String"]
User fixes to "x + 1":
1. set_text() → new revision
2. type_check() → re-runs
3. parse() → re-runs (source changed)
4. infer_type() → re-runs for x + 1
5. infer_type(x) → cached! (same revision, same env)
6. infer_type(1) → cached! (literal, no dependencies)
7. No diagnostics emitted
8. diagnostics() → [] (old diagnostics automatically discarded)
Steps 5-6 are the magic. Even though the source changed and parse() re-ran, the individual infer_type calls for unchanged sub-expressions return from cache. The type checker only does work for the part that actually changed.
Per-File Isolation
#![allow(unused)] fn main() { let main = server.open_file("main.lua", "local x = 42\n"); let other = server.open_file("other.lua", "local a = 1 + \"hello\"\n"); // other.lua has an error server.diagnostics(other); // → [Error: cannot add Number and String] // main.lua is clean — and it stays cached server.diagnostics(main); // → [] (no re-checking!) }
When we open a second file with an error, the first file isn't affected. Its cached type check is still valid. Salsa knows that main.lua's queries don't depend on other.lua's source text, so it returns the cached result instantly.
In a real project with hundreds of files, this is the difference between "responsive on every keystroke" and "freezes for seconds after each edit."
The Full Architecture
┌──────────────────────────────────────────────────────────────┐
│ LANGUAGE SERVER │
│ (diagnostics, hover, completion) │
└──────────────┬───────────────────────────────────────────────┘
│ accumulated diagnostics / cached types
▼
┌──────────────────────────────────────────────────────────────┐
│ TYPE CHECKER │
│ type_check() → check_stmt() → infer_type() │
│ ↓ ↓ ↓ │
│ [accumulators] [env updates] [recursive memoized calls] │
└──────────────┬───────────────────────────────────────────────┘
│ depends on
▼
┌──────────────────────────────────────────────────────────────┐
│ PARSER │
│ parse() — tracked, cached, uses analisar │
└──────────────┬───────────────────────────────────────────────┘
│ depends on
▼
┌──────────────────────────────────────────────────────────────┐
│ INPUTS │
│ SourceFile { path, text } — set on edit │
└──────────────────────────────────────────────────────────────┘
Incrementality in action:
set_text()creates a new revisionparse()re-runs (source changed)type_check()re-runs (parse result changed)infer_type()re-runs only for affected expressions- Diagnostics are re-accumulated
- Other files are NOT re-checked
This is how rust-analyzer stays fast. And now you know how.
Running
cargo run --bin ch07-language-server
What You Built
Over seven chapters, you built a gradual type checker for Lua powered by Salsa:
- Ch1 — Salsa basics: inputs, tracked functions, revisions
- Ch2 — Parsing Lua: wiring analisar into Salsa
- Ch3 — Interned symbols: fast name comparison by ID
- Ch4 — Tracked structs: entity identity in Salsa
- Ch5 — Type inference: the core tracked query, recursive and cached
- Ch6 — Accumulators: diagnostics without poisoning the graph
- Ch7 — Language server: the full pipeline, incremental on edits
Next Steps
This tutorial covers the fundamentals. To go further:
- Build a real LSP using
tower-lsp— the simulation here shows the loop, but a real server needs JSON-RPC, text document sync, etc. - Add type annotations — Teal-style
local x: number = 42. Parse annotations and use them as hints in inference. - Fine-grained incrementality — Use tracked structs for every AST node, not just function definitions. This is what rust-analyzer does.
- Go-to-definition, completion, hover — These are queries too.
lookup_namefrom Chapter 3 is the start of go-to-definition. - Cross-file references — Our type checker is per-file. A real one needs to handle
require()and resolve names across files. Tracked structs make this tractable: each file's definitions are tracked, and cross-file lookups are cached.