Deep dive
How it works
Parts:
- Transport — byte stream (stdio, socket); header + body framing, see Length-prefix framing.
- Message layer — JSON-RPC 2.0: request (has
id, needs exactly one response), response (resultorerror), notification (noid, no reply). - Document store — server-side text of every open file, keyed by URI, with a version number from the client.
- Analysis engine — the language's parser and checker; ideally incremental and demand-driven (query-based).
- Scheduler — reads messages in order, applies edits, runs requests (possibly in parallel), cancels stale work.
- Frame. Client writes
Content-Length: N\r\n\r\n{json}(Content-Typeoptional, UTF-8 only). Server reads headers to the blank line, then exactly N bytes. - Initialize. Client sends
initialize(processId,capabilities,general.positionEncodings); until the server answers, it sends nothing else. The result carriescapabilities:textDocumentSync,positionEncoding,diagnosticProvider, and so on. - Sync.
didOpencarries the whole text and a version;didChangecarriescontentChanges. Sync kind 1 (Full): one change with the entire new text. Kind 2 (Incremental): ranges{range, text}applied in order, each against the result of the previous one; positions use the negotiated encoding (Text position encoding). - Diagnostics, push. The server decides when to send
textDocument/publishDiagnostics(full list per URI, replaces the previous list). Pull (3.17): the client sendstextDocument/diagnosticwith an optionalpreviousResultId; the server answersfullorunchanged, and may refuse withServerCancelled(retrigger by default). - Requests. Hover, completion, definition: responses may be reordered where correctness allows (completion vs signature help yes, definition vs rename no).
- Cancellation. The client sends
$/cancelRequest {id}. The server must still answer that id, ideally with error -32800 (RequestCancelled); a partial result is allowed. A server may ignore$/notifications.-32801ContentModifiedmeans "the document changed under this request"; clients retry only if they can rebuild the request. - Progress.
$/progresswith a token:workDoneTokenin a request streams progress,partialResultTokenstreams partial results.
editor ──didChange v7──► [scheduler] ──apply edit──► [doc store v7]
◄──publishDiagnostics (v7)── │ │
editor ──hover(id 9)────► └─►[analysis, snapshot v7]──► response id 9
editor ──didChange v8 + $/cancelRequest 9──► snapshot v7 dropped, v8 built
Server architecture
- Apply edits first, analyse later. Updating the document store is O(edit); analysis runs on a snapshot or on demand. rust-analyzer's design: salsa keeps a revision counter, applying a change bumps it and waits for running readers, and a reader that notices the bump unwinds with a
Cancelledvalue that the API boundary turns into a retry (its architecture doc). - Debounce diagnostics. Apply edits at once; expensive checks wait for a quiet period (Debouncing). Clients also debounce
didChange(Neovim:flags.debounce_text_changes, default 150 ms). - Prioritise. Interactive requests (completion, hover) run before background checks; syntactic diagnostics before semantic ones.
- Stale results. Tag work with the document version; before sending, compare it with the current one; drop or answer
ContentModified. - Precomputed indexes. Cold start on a big repository is slow, so CI precomputes navigation data: LSIF (a graph of vertices and edges stored as JSON) and Sourcegraph's SCIP (a Protobuf schema with human-readable symbol strings instead of LSIF's monikers and result sets) answer definition/references without running the language server. See Code symbol indexing.
Code
A std-only server skeleton: Content-Length reader and writer, a field extractor (no serde; first match only, fine for a demo), and a dispatcher for initialize, didOpen, didChange (full sync) that publishes a warning for every line containing TODO. The test asserts on framed output, including a UTF-16 column after an emoji.
use std::collections::HashMap;
use std::io::{self, BufRead, Read, Write};
/// Read one framed message; None on clean EOF.
fn read_message<R: BufRead>(r: &mut R) -> io::Result<Option<String>> {
let mut len = None;
loop {
let mut line = String::new();
if r.read_line(&mut line)? == 0 { return Ok(None); }
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() { break; } // blank line ends the header
if let Some((k, v)) = line.split_once(':') {
if k.eq_ignore_ascii_case("content-length") { len = v.trim().parse::<usize>().ok(); }
} // Content-Type and unknown headers are ignored
}
let n = len.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no Content-Length"))?;
let mut body = vec![0u8; n]; // length is in bytes, not chars
r.read_exact(&mut body)?;
String::from_utf8(body).map(Some).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
fn write_message<W: Write>(w: &mut W, body: &str) -> io::Result<()> {
write!(w, "Content-Length: {}\r\n\r\n{}", body.len(), body)?;
w.flush()
}
/// Raw value of the first `"key":` in `json`: string contents unescaped, or a bare token.
fn field(json: &str, key: &str) -> Option<String> {
let pat = format!("\"{key}\":");
let rest = json[json.find(&pat)? + pat.len()..].trim_start();
if let Some(s) = rest.strip_prefix('"') {
let (mut out, mut it) = (String::new(), s.chars());
while let Some(c) = it.next() {
match c {
'"' => return Some(out),
'\\' => out.push(match it.next()? { 'n' => '\n', 't' => '\t', 'r' => '\r', o => o }),
c => out.push(c),
}
}
None
} else {
Some(rest.split([',', '}', ']']).next()?.trim().to_string())
}
}
#[derive(Default)]
struct Server { docs: HashMap<String, String> }
impl Server {
/// One incoming message -> zero or more outgoing messages.
fn handle(&mut self, msg: &str) -> Vec<String> {
let (id, method) = (field(msg, "id"), field(msg, "method").unwrap_or_default());
match (method.as_str(), id) {
("initialize", Some(id)) => vec![format!(
r#"{{"jsonrpc":"2.0","id":{id},"result":{{"capabilities":{{"textDocumentSync":1,"positionEncoding":"utf-16"}}}}}}"#)],
("shutdown", Some(id)) => vec![format!(r#"{{"jsonrpc":"2.0","id":{id},"result":null}}"#)],
("textDocument/didOpen" | "textDocument/didChange", None) => {
let (Some(uri), Some(text)) = (field(msg, "uri"), field(msg, "text")) else { return vec![] };
let out = publish(&uri, &text); // full sync: `text` is the whole new document
self.docs.insert(uri, text);
vec![out]
}
(_, Some(id)) => vec![format!( // unknown request must be answered, notifications are dropped
r#"{{"jsonrpc":"2.0","id":{id},"error":{{"code":-32601,"message":"method not found"}}}}"#)],
_ => vec![],
}
}
}
fn publish(uri: &str, text: &str) -> String {
let diags: Vec<String> = text.lines().enumerate().filter_map(|(n, l)| {
let b = l.find("TODO")?;
let col: usize = l[..b].chars().map(char::len_utf16).sum(); // LSP columns: UTF-16 units
Some(format!(r#"{{"range":{{"start":{{"line":{n},"character":{col}}},"end":{{"line":{n},"character":{}}}}},"severity":2,"message":"TODO"}}"#, col + 4))
}).collect();
format!(r#"{{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{{"uri":"{uri}","diagnostics":[{}]}}}}"#, diags.join(","))
}
fn serve<R: Read, W: Write>(input: R, mut out: W) -> io::Result<()> {
let mut r = io::BufReader::new(input);
let mut s = Server::default();
while let Some(msg) = read_message(&mut r)? {
for reply in s.handle(&msg) { write_message(&mut out, &reply)?; }
}
Ok(())
}
fn main() -> io::Result<()> {
let frame = |b: &str| format!("Content-Length: {}\r\n\r\n{}", b.len(), b);
let script = [
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null}}"#,
r#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#,
r#"{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///a.rs","languageId":"rust","version":1,"text":"fn a() {}\n"}}}"#,
r#"{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///a.rs","version":2},"contentChanges":[{"text":"fn a() {}\n// é😀 TODO fix\n"}]}}"#,
r#"{"jsonrpc":"2.0","id":2,"method":"foo/bar"}"#,
].map(frame).concat();
let mut out = Vec::new();
serve(script.as_bytes(), &mut out)?;
let want = [
r#"{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"textDocumentSync":1,"positionEncoding":"utf-16"}}}"#,
r#"{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///a.rs","diagnostics":[]}}"#,
r#"{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///a.rs","diagnostics":[{"range":{"start":{"line":1,"character":7},"end":{"line":1,"character":11}},"severity":2,"message":"TODO"}]}}"#,
r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"method not found"}}"#,
].map(frame).concat();
assert_eq!(String::from_utf8(out).unwrap(), want);
io::stdout().write_all(b"ok\n")
}
Compiled and run; the assert passes.
Complexity and constants
- Framing: O(bytes); a buffered reader costs one syscall per few KB, not per header line.
- Full sync costs O(file) bytes on the pipe and in JSON parsing per change; incremental sync costs O(edit). Counted by me: a one-character insertion is an 88-byte change entry in a 220-byte
didChange; full sync sends the whole file instead. - Cost drivers: bytes across the pipe, JSON parse/serialise, reanalysis per keystroke, diagnostics per second; no fsync (nothing is persisted). Cheap: incremental sync, a bounded work queue, version-tagged results, a persistent index for cold start.
Efficiency
- Cost: per edit, pipe bytes plus reanalysis; per request, analysis time against an interactive budget of order 100 ms (rule of thumb, not from the spec).
- Levers:
- Incremental sync — 220 bytes per keystroke vs the whole file (self-counted) — large files.
- Debounce
didChange/diagnostics — fewer wasted analyses while typing (Neovim default 150 ms, see itslsp.txt) — slow checkers. - Cancellation — abandon stale work; rust-analyzer unwinds salsa computations after a revision bump (its architecture doc) — long requests.
- Memoised queries — redo only what changed (Query-based incremental computation) — many-file projects.
- Precomputed index (SCIP/LSIF) — Sourcegraph reports a 10x CI speedup replacing
lsif-nodewithscip-typescript, partly not due to the protocol (announcement post) — navigation on a repository nobody is editing. - Pull diagnostics —
unchangedreplies viapreviousResultIdskip resending lists (spec 3.17) — many open documents.
- Naive → standard → state of the art:
- Full sync, reparse and recheck the project on each change · O(project) per keystroke
- Incremental sync, per-file reparse, debounced diagnostics, cancellation · O(edit + dependents)
- Query-level memoisation with early cutoff, request prioritisation, persisted index for cold start · O(changed queries)
Implementation notes
- Read stdin on its own thread; write frames from one place so they never interleave.
- Keep text in a rope or line-indexed buffer so ranges apply in O(log n); store the version beside the text.
- Log to stderr, never to stdout (the protocol channel).
Pitfalls
Content-Lengthcounts bytes, not characters; a length from aStringofchars()breaks on non-ASCII.- Applying incremental edits against the original text, not the result of the previous edit.
- Publishing diagnostics for version 7 after accepting version 8: wrong squiggles.
- Analysing inside the message loop: one slow request blocks edits and cancels cannot be read.
- Dropping a cancelled request without a response: the spec requires one for every request.
Variants and alternatives
In production
- Editors and IDEs: VS Code (client library
vscode-languageclient), Neovim (built-in client), Helix and Zed ship LSP clients. - Config and infrastructure:
yaml-language-server,terraform-ls(Helix's default config lists both). - Query languages: SQL servers such as
sqls. - Documentation and markup:
texlabfor LaTeX,marksmanfor Markdown,ltex-lsfor grammar checking. - Notebook and data tools:
jupyterlab-lspadds language servers to JupyterLab.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| vscode-languageserver-node | TypeScript | microsoft/vscode-languageserver-node | npm install vscode-languageserver | — | — | — | — | reference client/server libraries, includes JSON-RPC |
| lsp-types | Rust | gluon-lang/lsp-types | cargo add lsp-types | — | — | — | — | protocol structs for Rust servers |
| rust-analyzer | Rust | rust-lang/rust-analyzer | rustup component add rust-analyzer | — | — | — | — | salsa-based incremental server with cancellation |
| scip | Go/Rust | sourcegraph/scip | n/a (schema and CLI) | — | — | — | — | precomputed code index format |
| lsif-node | TypeScript | microsoft/lsif-node | npm install -g lsif | — | — | — | — | LSIF indexer for TypeScript |
Sources
- Microsoft, Language Server Protocol Specification 3.17. spec
- Microsoft, Language Server Index Format 0.6.0. spec
- JSON-RPC Working Group, JSON-RPC 2.0 Specification. spec
- rust-analyzer, Architecture (salsa, cancellation). doc
- Sourcegraph, Announcing SCIP. post · code
- Neovim,
runtime/doc/lsp.txt(debounce_text_changes). code