Home › Compilers & runtimes › Developer tooling
algorithm medium

Language server protocol

Problem Editors, notebooks and documentation tools each want completion, diagnostics and navigation for every language; writing one plugin per editor and language multiplies the work and lets each drift from the real compiler.

One JSON-RPC conversation over a pipe lets any editor drive any language's analysis server; the cost is keeping a live, edited document in sync and answering before it changes.

framing O(message bytes); full sync O(file) per change, incremental sync O(edit); request cost depends on the server's analysis
language-serverjson-rpceditorsdiagnosticsincremental-analysislsifscip
Overview

Every editor times every language used to need its own plugin. LSP splits them: the editor (client) spawns the analysis program (server), and they exchange JSON-RPC messages over stdio, each prefixed with a Content-Length header. The hard part is not the wire format but answering about text that is still changing.

Key facts

Use when

  • Editors and IDEs (VS Code, Neovim, Helix, Zed) want one analysis server per language instead of one plugin per editor.
  • Config and infrastructure files (YAML, Terraform), query languages (SQL), markup (LaTeX, Markdown) and notebook cells need completion and validation too.

Avoid when

  • The tool is a one-shot batch checker: run it as a CLI.

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 (result or error), notification (no id, 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.
  1. Frame. Client writes Content-Length: N\r\n\r\n{json} (Content-Type optional, UTF-8 only). Server reads headers to the blank line, then exactly N bytes.
  2. Initialize. Client sends initialize (processId, capabilities, general.positionEncodings); until the server answers, it sends nothing else. The result carries capabilities: textDocumentSync, positionEncoding, diagnosticProvider, and so on.
  3. Sync. didOpen carries the whole text and a version; didChange carries contentChanges. 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).
  4. Diagnostics, push. The server decides when to send textDocument/publishDiagnostics (full list per URI, replaces the previous list). Pull (3.17): the client sends textDocument/diagnostic with an optional previousResultId; the server answers full or unchanged, and may refuse with ServerCancelled (retrigger by default).
  5. Requests. Hover, completion, definition: responses may be reordered where correctness allows (completion vs signature help yes, definition vs rename no).
  6. 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. -32801 ContentModified means "the document changed under this request"; clients retry only if they can rebuild the request.
  7. Progress. $/progress with a token: workDoneToken in a request streams progress, partialResultToken streams 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 Cancelled value 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 its lsp.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-node with scip-typescript, partly not due to the protocol (announcement post) — navigation on a repository nobody is editing.
    • Pull diagnostics — unchanged replies via previousResultId skip 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-Length counts bytes, not characters; a length from a String of chars() 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-process pluginone editor, deep integration, no process boundary.
Tree-sitter onlyhighlighting and folding without a server.
LSIF / SCIPread-only code navigation from a prebuilt index, no live editing.

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: texlab for LaTeX, marksman for Markdown, ltex-ls for grammar checking.
  • Notebook and data tools: jupyterlab-lsp adds language servers to JupyterLab.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
vscode-languageserver-nodeTypeScriptmicrosoft/vscode-languageserver-nodenpm install vscode-languageserver————reference client/server libraries, includes JSON-RPC
lsp-typesRustgluon-lang/lsp-typescargo add lsp-types————protocol structs for Rust servers
rust-analyzerRustrust-lang/rust-analyzerrustup component add rust-analyzer————salsa-based incremental server with cancellation
scipGo/Rustsourcegraph/scipn/a (schema and CLI)————precomputed code index format
lsif-nodeTypeScriptmicrosoft/lsif-nodenpm 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

Used by

Systems (8):

Alternatives and related