Chapter 7: The Language Server — Watching Type Errors Appear as You Type
You’ve built the pieces individually. Now the question is: do they work together? This chapter wires everything — inputs, tracked functions, interned symbols, tracked structs, type inference, diagnostics — into a language server loop and watches what happens when you edit a file.
A language server does the same thing on every keystroke: parse, type check, report diagnostics. The hard part isn’t the logic — it’s the speed. You need sub-100ms responses, which means you can’t re-type-check the entire project each time. Salsa’s incrementality makes this possible: change a comment and the type checker doesn’t re-run at all. Change a function body and only queries that depended on that function re-run. Let’s see it in action.
The Language Server Loop
Here’s what that loop looks like in detail:
- 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 — and this time, we’ll measure it.
The LanguageServer Struct
#![allow(unused)]
fn main() {
use salsa::Setter;
use salsa::Accumulator;
use std::path::PathBuf;
struct LanguageServer {
db: Database, // owned — set_text needs &mut, but SourceFile IDs
// remain valid after mutation (Salsa guarantees this)
files: Vec<SourceFile>,
}
impl LanguageServer {
fn open_file(&mut self, path: &str, text: &str) -> usize {
let file = SourceFile::new(&self.db, PathBuf::from(path), 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.
You can see this in the timing output. The first open_file call does full work. The edit_file calls are faster because Salsa skips the cached sub-expressions. With a small file the difference is subtle — with a thousand-line file, it’s the difference between “instant” and “wait for it.”
Running
cargo run --bin ch07-language-server
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.
Seeing the Speedup
The demo prints timing information to stderr. Here’s what you’ll see:
1. User opens main.lua
[timing] open "main.lua": 1.23ms
2. User adds a type error: local w = x + "hello"
[timing] edit: 0.18ms
3. User fixes the error: local w = x + 1
[timing] edit: 0.15ms
4. User opens other.lua with an error
[timing] open "other.lua": 0.95ms
The first check of each file does full work: parse the source, walk every expression, compute every type. Subsequent edits are faster because Salsa re-runs only what changed. The parse re-runs (the source changed), but infer_type for unchanged sub-expressions returns from cache.
These numbers are illustrative — with small demo files, both operations complete in microseconds and the timing difference is negligible. The effect is dramatic with larger codebases: a thousand-line file might take 50ms on first check but under 1ms on edit, because Salsa skips the cached sub-expressions.
The timing data goes to stderr so the main output stays clean. To see it:
cargo run --bin ch07-language-server 2>&1 | grep timing
Or run it without filtering — the timing lines print alongside the normal output.
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
Chapter 8: Cycle Detection — Recursive type queries can loop forever. Salsa detects cycles and reports them — but the implementation has subtlety. We’ll see how cycle detection works, why it matters for type checking, and how to handle it in your own queries.
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. (Covered in Chapter 10.)
Pursue fine-grained incrementality — use tracked structs for every AST node, not only function definitions. This is what rust-analyzer does.
Build go-to-definition, completion, hover — these are queries too. lookup_name from Chapter 3 is the start of go-to-definition.
Handle cross-file references — our type checker is per-file. A real one needs to handle require() and resolve names across files. (Covered in Chapter 9.) Tracked structs make this tractable: each file’s definitions are tracked, and cross-file lookups are cached.
Next: Chapter 8: Cycle Detection — Our type checker assumes queries form a clean dependency graph: parse feeds into infer_type, data flows one direction. But what happens when f calls g and g calls f? Without protection, Salsa loops forever. We’ll see how to catch cycles and keep the language server alive.