Language Server Protocol (LSP)

LSP integration brings IDE features — go-to-definition, hover, diagnostics, completion, rename, code actions, formatting, inlay hints, and more — into lattice through a clean wire protocol. Every modern language ships an LSP server; lattice talks to them all without per-language plugins.

The architecture is non-negotiable on latency: every LSP request is asynchronous, every response arrives via the snapshot model (§5.6.8), and a slow or crashed server cannot stall the editor. The four paramount goals (CLAUDE.md) apply unchanged: performance, extensibility, modal editing, asynchronicity.

Status: Phase 4.1 (foundation) complete. Phase 4.2 (navigation) 9/12 + 1 partial: hover (K), definition family (gd / gD / gy / gI), references (gr), document outline (:lsp-symbols), workspace symbols (:lsp-workspace-symbol), completion picker bridge (:complete). Phase 4.3 (edits) 9/9 shipped: formatting (:format / :format-range) + Insert-mode onTypeFormatting autopilot; signatureHelp (:signature-help + Insert-mode autopilot); rename + prepareRename (:rename); codeAction + resolve + executeCommand (:code-actions); willSave + didSave + willSaveWaitUntil format-on-save fan-out from :write / :w. All multi-result LSP lookups + :diagnostics route through a unified vertico picker; tag stack <C-t> pops drill-down chains. Per-feature status tracked in ../dev/notes/lsp-features.md.


What LSP gives you

FeatureWhat it does
DiagnosticsErrors, warnings, hints rendered as gutter glyphs + inline underlines, plus a :diagnostics buffer for the full list. Pull-mode (textDocument/diagnostic) is used when the server advertises it; push is used otherwise.
HoverPress K on a symbol to see its type signature, doc comment, or runtime info in a popup.
Go-to-definitiongd jumps to where a symbol is defined; gD for declaration; gy for type definition; gI for implementations. The position-history ring (§5.1.1) records each jump so <C-o> walks back; <C-t> pops the tag stack distinct from the jump list.
Find referencesgr opens a vertico picker of every reference; navigate with j / k and <CR> to jump.
Document symbols:lsp-symbols opens the document's symbol picker (Flat and Nested response shapes both supported).
Workspace symbols:lsp-workspace-symbol [query] opens a fuzzy picker over workspace symbols across every attached server.
CompletionTriggered automatically in Insert mode; merges LSP suggestions with buffer-words, snippets, tree-sitter symbols, and path completion. Snippet expansion, lazy completionItem/resolve, ghost text, and commit chars all supported.
Signature helpFunction-call popup with parameter highlighting. Triggers automatically after ( or , in supported languages.
Code actions:lsp-code-action (alias :ca) opens a picker of available quick fixes / refactors. Lazy codeAction/resolve runs when needed; Command payloads route through workspace/executeCommand; multi-file edits apply per-file as one undo unit each.
Rename:lsp-rename <new-name> (alias :rn) renames across the workspace; prepareRename pre-populates the new name when applicable.
Format:lsp-format formats the whole buffer; :lsp-format-range formats the active Visual selection; on-type formatting fires automatically after server-advertised trigger characters in Insert mode. Format-on-save flows through willSaveWaitUntil.
Document highlightParking the cursor on an identifier paints a soft bg tint on every occurrence (read / write / text variants). Gated by lsp-document-highlight-mode.
Inlay hintsType annotations, parameter names, lifetime hints rendered as italic dim virtual text. Gated by lsp-inlay-hint-mode (toggle with :lsp-inlay-hint-mode).
Semantic tokensLSP-aware syntax highlighting that augments tree-sitter (e.g. distinguishes mutable vs immutable bindings, type vs value namespaces). full + full/delta paths both used. Gated by lsp-semantic-tokens-mode.
Folding:set foldmethod=lsp uses server-driven folds via textDocument/foldingRange; gated by lsp-folding-mode. See folding.md.
Selection range:lsp-expand-region widens Visual selection to the next semantic range (token → expr → block → fn); :lsp-shrink-region walks back. Gated by lsp-selection-range-mode.
Call hierarchy:lsp-incoming-calls / :lsp-outgoing-calls open pickers of callers / callees at the cursor.
Type hierarchy:lsp-supertypes / :lsp-subtypes open pickers of super / sub types at the cursor.
Code lens:lsp-code-lens opens a picker of code lenses (rust-analyzer surfaces Run / Debug / References); accept fires the lens command.
Document linksgx on a URL inside a doc-comment / markdown link follows it (file:// via :e; external URIs via the OS handler).
Document color:lsp-color-presentation on a color literal opens a picker of alternative representations; accept splices the chosen form.
Moniker:lsp-moniker echoes the cross-repo moniker(s) at the cursor (scheme:identifier (kind) form) — niche feature aimed at code-indexer / cross-repo workflows.
ProgressServer $/progress notifications accumulate per (server_id, token); the most-recent active entry surfaces in the modeline [<server>: <task>] segment. :lsp-progress-cancel <server> cancels cancellable entries. Gated by lsp-progress-mode.

The full feature matrix (every LSP 3.17 capability + status) lives in ../dev/notes/lsp-features.md.


Setup

Installing servers

lattice does not bundle servers; install the one(s) you need through your language's standard channel. The defaults expect the canonical binary on PATH:

LanguageBinaryInstall
Rustrust-analyzerrustup component add rust-analyzer
Pythonpyright-langservernpm install -g pyright
Gogoplsgo install golang.org/x/tools/gopls@latest
TypeScript / JavaScripttypescript-language-servernpm install -g typescript-language-server typescript
C / C++clangdapt install clangd / brew install llvm
Lualua-language-serverbrew install lua-language-server

Other servers can be configured manually (see Configuration below).

What lattice does on file open

When you open a buffer, lattice:

  1. Detects the language from the file extension (and a future content-pattern fallback for shebang scripts).
  2. Resolves the workspace root by walking up from the buffer's directory looking for marker files (.git, Cargo.toml, pyproject.toml, go.mod, package.json, compile_commands.json, ...). Falls back to the buffer's directory if nothing matches.
  3. Spawns the server if no actor is already running for (workspace, language). The server's initialize / initialized handshake completes before the buffer is considered "attached".
  4. Sends didOpen with the buffer's text and language id. The server can now report diagnostics and answer queries.

If the server isn't on PATH, lattice tells you in the modeline. The buffer still opens — LSP failure never blocks editing.


The 4.2 navigation surface is wired through one consistent shape: K for hover, g-prefixed chords for jumps, :lsp-* ex commands for outlines / workspace search. Multi-result lookups + :diagnostics open a vertico picker (type-to-filter, <CR> to jump, <Esc> to dismiss); single-result jumps go directly per vim convention.

Hover

KeystrokeMeaning
KShow LSP hover documentation in a tooltip popup near the cursor (markdown-rendered).
K (again)Move focus into the popup. Full vim grammar inside (search, motions, scroll); <Esc>/q returns.

Jumps

All four use the same dispatch path; differs only in which LSP method runs server-side. Single result jumps directly; multi-result opens the picker. Two persistence shapes record where you've been:

  • Jump list (<C-o> back, <C-i> forward) -- chronological history of every "big jump". Every LSP nav, picker accept, search submit, n/N repeat, and gg/G-style motion pushes onto it.
  • Tag stack (<C-t> to pop) -- LIFO chain of gd / gD / gy / gI drill-downs only. Distinct from the jump list: the user's mental model for <C-t> is "undo the drill-down chain", not "step through every cursor jump". The two have different lengths and different push semantics.

<C-o> and <C-t> coexist deliberately: walk a chain of five gd calls, then <C-t> pops one drill-down at a time back to the original symbol; <C-o> walks every cursor position you've visited along the way (which may include references-picker rows, diagnostics jumps, etc.).

ChordLSP methodWhat it asks
gdtextDocument/definitionWhere is the symbol defined?
gDtextDocument/declarationWhere is the forward declaration / extern?
gytextDocument/typeDefinitionWhere is the type of the expression defined?
gItextDocument/implementationWhere are the implementations of this trait / interface? Lowercase gi is reserved for vim's "go to last insert position".

Lists

Keystroke / commandPicker over
grtextDocument/references -- every call site of the symbol under cursor (include_declaration: true).
:lsp-symbolstextDocument/documentSymbol -- the active document's outline; nested DocumentSymbol responses keep their hierarchy as picker indent.
:lsp-workspace-symbol [q]workspace/symbol -- workspace-scoped symbols matching q (server-side substring filter). Empty q returns the server's idea of "every workspace symbol". Fans out across every running server, not just the active buffer's.
:diagnosticsEvery workspace diagnostic across attached servers; severity in marginalia.
:completeLSP completion items at the cursor (textDocument/completion) — the cmdline-driven peer of the Insert-mode auto-trigger. Full surface: snippet expansion, lazy completionItem/resolve, ranking, ghost text, commit chars, cross-source dedup.

Edits

CommandEffect
:format (:fmt)textDocument/formatting -- whole buffer; highest-priority server with the provider; one undo unit.
:format-rangetextDocument/rangeFormatting over the active Visual selection (or whole buffer when not in Visual).
:signature-helptextDocument/signatureHelp -- popup with the active signature + parameter highlight. Auto-fires in Insert mode on server-advertised trigger characters (typically ( and ,).
:rename <name> (:rn)textDocument/rename after textDocument/prepareRename. Replaces the symbol under cursor across the workspace. Empty <name> uses the server's prepareRename placeholder. Active buffer's edits apply as one undo unit; cross-file edits open via :e and apply per-file.
:code-actions (:ca)textDocument/codeAction -- vertico picker over quick-fixes, refactors, and source actions at the cursor / Visual selection. Accept resolves lazy actions via codeAction/resolve, applies inline WorkspaceEdits via the rename apply path, and routes Command payloads through workspace/executeCommand.

Each picker entry encodes path:line:col in its candidate text; accept dispatch parses through jump_to_file_line_col which pushes the pre-jump cursor onto position history. So the same <C-o> dance works regardless of whether you took a single-result gd jump or picked one of N references.


Diagnostics

When the server reports problems, lattice renders them in four places:

  • Gutter — a glyph in the line-number column for every line with diagnostics (severity-coloured: red for error, yellow for warning, blue for info, grey · for hint). The most severe wins on lines with multiple.
  • Inline — squiggly underline under the affected range (utf-8 / utf-16 column converted from the server's units).
  • End-of-line summary — a short trailing annotation on the cursor's line, in the severity colour, showing the most-severe message (truncated) plus +N when the line carries more. It appears ~300 ms after the cursor settles on a line (so it doesn't flash while you move through the file), is hidden while you type (Insert/Replace), and tracks only the focused cursor. Tune it with two options:
    • ui.diagnostics.inlineoff, cursor-line (default), or all (every viewport line; opt-in).
    • ui.diagnostics.inline-min-severityerror, warning, info, or hint (default; show everything). Only diagnostics at or above this level count toward the summary.
  • :diagnostics picker — opens a vertico picker over every workspace diagnostic. Severity glyph ([E]/[W]/[I]/[H]) rides in the marginalia; type to filter; <CR> jumps; <Esc> dismisses.

Diagnostic navigation

Keystroke / commandMeaning
]dJump to the next diagnostic in the active buffer (wraps), echoing its message.
[dJump to the previous diagnostic (wraps), echoing its message.
glOpen a cursor-anchored popup listing every diagnostic on the cursor line (severity glyph, message, source/code, related-info count).
:diagnosticsOpen the workspace diagnostics picker.
:diag-clearDrop the renderer's overlay for the active buffer (server may republish).

Diagnostics are separate from the error list. :next-error / :cnext walk the error list (compiler / tool output), not diagnostics — use ]d / [d / :diagnostics for LSP diagnostics.

]d / [d / gl are owned by lsp-diagnostics-mode — they fire only in buffers with LSP diagnostics active.

Diagnostics are version-tracked — if the server publishes diagnostics for a stale doc version (because an edit raced with the publish), lattice drops the stale ones rather than overwriting fresher state.


Configuration

Most users never need to configure anything: the defaults match how each server's docs say to invoke it. Override only when you need a non-standard binary path or initialization options.

Where config lives

LSP configuration is part of lattice's options system (§5.12). Until the typed-options layer ships, lsp.toml at the workspace root provides overrides:

# lsp.toml at workspace root
[server.rust]
binary = "/opt/rust-analyzer-nightly/rust-analyzer"
args = []
root_markers = ["Cargo.toml", "rust-project.json"]
file_patterns = ["*.rs"]

[server.rust.initialization_options]
checkOnSave.command = "clippy"
cargo.features = ["all"]

[server.python]
binary = "pyright-langserver"
args = ["--stdio"]

The keys mirror ServerConfig (see ../dev/architecture/lsp-architecture.md for the schema).

Per-language overrides

A user-level ~/.config/lattice/lsp.toml is merged on top of the workspace file. Workspace wins on key collision.

Disabling LSP for a buffer

:set nolsp (per-buffer) detaches the server for that buffer without affecting others. Re-attach with :set lsp.


Performance discipline

Every commitment in §8.2 holds with LSP attached:

  • No request blocks the UI. Every ServerHandle::request / notify returns immediately; the editor's input pipeline never awaits a server response synchronously.
  • didChange is debounced. Every keystroke commits one edit but doesn't emit didChange until ~50ms of idle. A fast typist won't generate one wire message per keystroke.
  • Diagnostics are version-gated. Stale publishes are silently dropped.
  • Cancellation is real. A pending request can be cancelled via $/cancelRequest + the Pending resolves locally; superseding a stale completion request is a one-line API.
  • Crashed servers don't take the editor down. The actor detects pipe close, the supervisor restarts with backoff, and pending requests resolve with a clear ActorGone error.

The benches in crates/lattice-lsp/benches/lsp.rs measure the wire-layer cost (framing, encode/decode, position conversion) — all sit in the nanosecond-to-microsecond range, well below human-perceptible latency.


Debugging & logs

LSP integration ships with a layered logging surface modelled after emacs's *lsp-log* / *<server> stderr* buffers, on top of lattice's everything-is-a-buffer foundation.

Log buffers

BufferContentsOpen with
*lsp*Subsystem-wide events: server spawn / handshake / crash / restart, supervisor decisions.:lsp-log
*lsp:<server-id>*Per-server: stderr lines, window/logMessage and window/showMessage notifications at the server-requested severity, lifecycle events, decode failures, capability gating decisions.:lsp-log <server>
*lsp:<server-id>:trace*Full JSON-RPC wire trace -- every inbound () and outbound () message, body truncated at ~240 chars. Off by default.:lsp-trace <server> toggles + opens.

Each buffer is read-only, auto-scrolls to tail when you're at the bottom, and keeps a bounded ring (10 000 records / server by default; oldest evicts first).

Log levels

Five levels, matching tracing / RUST_LOG conventions:

LevelWhat it captures
errorUnrecoverable failures (server died, framing rejected, decode totally broken).
warnRecoverable problems: stderr lines, unexpected protocol behaviour, malformed payloads dropped.
infoDefault. Lifecycle milestones (handshake done, server attached).
debugPer-message detail: publishDiagnostics summaries, $/progress events, telemetry.
traceJSON-RPC wire trace. Only emitted when trace mode is on for the server (otherwise short-circuited cheaply -- ~9 ns/call when off).

The default min level is info; per-server overrides allowed.

Commands

CommandEffect
:lsp-logOpen *lsp*.
:lsp-log <server>Open *lsp:<server>* (e.g. :lsp-log rust).
:lsp-trace <server>Toggle JSON-RPC trace + open the trace buffer.
:lsp-statusShow every running server (id, root, pid, uptime, restart count).
:lsp-restart <server>Force-restart a server; re-issues didOpen for every attached buffer.
:lsp-log-level <level>Subsystem-wide default min level.
:lsp-log-level <server> <level>Per-server min level override.
:lsp-log-clear [server]Drop the buffer's records.

Configuration

lsp.toml (workspace) + ~/.config/lattice/lsp.toml (user) accept these logging keys:

[lsp]
log_level    = "info"   # subsystem-wide default
log_capacity = 10000    # records per ring (per server + global)

[server.rust]
log_level = "debug"     # override for this server
trace_io  = true        # turn on JSON-RPC trace at startup

Every key is optional; defaults are conservative (info / 10000 / no trace).

Tracing-crate fall-through

Every record LspLogger emits also fires a tracing::* event at the matching level. Two consequences:

  • Users who run RUST_LOG=lattice_lsp=debug ./lattice see the same events on stderr as in the buffer views.
  • Custom tracing_subscriber setups (JSON logs, OpenTelemetry, ...) work without further wiring.

The two paths are independent: in-memory rings survive even when no tracing subscriber is installed; tracing users see the same events whether or not buffer views are open.

Debugging workflows

"Server isn't returning hover."

  1. :lsp-log rust -- look for handshake errors or "server request unhandled" entries.
  2. :lsp-status shows the server's advertised capability set. If hoverProvider isn't there, the server doesn't support it.
  3. :lsp-trace rust -- watch for → Request method=textDocument/hover then the matching ← Response id=.... A request without a response means the server is stuck.

"Diagnostics are stale."

  1. :lsp-trace rust then look for ← Notification method=textDocument/publishDiagnostics. Compare its version field against lattice's DocSync version -- stale publishes are correctly dropped.
  2. *lsp:rust* shows publishDiagnostics: N diag(s) for <uri> summaries -- spot when the server stopped reporting.

"Server keeps crashing."

  1. *lsp:rust* has stderr lines (warn-level) and lifecycle read_loop terminating errors. Server stderr captures the panic / exit reason verbatim.
  2. :lsp-status shows restart counts.
  3. The supervisor restarts with exponential backoff (100ms → 5s) and re-issues didOpen for every attached buffer.

"Editor feels slow when LSP attached."

  1. The §8.2 commitment is that LSP plumbing never blocks the UI. If it does, that's a bug -- file an issue with cargo bench -p lattice-lsp numbers.
  2. *lsp:rust* may show a flood of $/progress -- a runaway indexer.
  3. Trace mode itself adds ~100 ns per traced message. Negligible at editor pace, perceptible only at indexer bursts.

Troubleshooting

"rust-analyzer: command not found" in the modeline

The server binary isn't on PATH. Either install it through your language's normal channel, or set an absolute path in lsp.toml:

[server.rust]
binary = "/full/path/to/rust-analyzer"

Server starts but no diagnostics appear

  1. Check the server's stderr in the log — lattice emits each line as a tracing warning with the server_id field. RUST_LOG=lattice_lsp=debug surfaces them at the terminal.
  2. Confirm the workspace root matches what the server expects. For rust-analyzer, this is the directory containing Cargo.toml. If lattice resolved the wrong root, edit lsp.toml to set root_markers explicitly.
  3. Some servers need warmup time on first open (rust-analyzer indexes the workspace). Wait a few seconds.

Diagnostics from a stale doc version

This shouldn't happen — lattice version-gates publishes — but if you see ghost diagnostics that don't match the current buffer, run :diag-clear to drop the overlay. The server should republish on the next idle tick.

Server crashed mid-session

The supervisor restarts with exponential backoff (100ms, 200ms, 400ms, ..., max 5s). After restart, lattice re-issues didOpen for every previously-open buffer the server cared about. If the crash is reproducible, file an issue with the server's stderr output.

:lsp-status

Shows every running server with PID, workspace root, attached buffer count, and uptime. Useful for confirming the server you expect is the one actually attached.


Power-user flows

Diagnostic navigation vs. the error list

Two separate systems — don't conflate them:

  • Diagnostics (this doc) — LSP-reported problems in your open buffers. ]d / [d walk diagnostics in the active buffer only (wraps); :diagnostics opens the workspace-wide diagnostics in a fuzzy picker. The per-pane cursor is the walk state, so two panes on the same file walk independently.
  • The error list — a separate, cross-file list populated by tool output (:compile, grep, …), walked with :next-error / :previous-error (vim :cnext / :cprev). It is not diagnostics: :next-error never touches the LSP diagnostic list, and an empty error list echoes no error list rather than falling through to diagnostics.

(Earlier builds aliased :cnext onto diagnostics; that fallback was removed — the two lists are now cleanly separate.)

  • :diagnostics buffer -- a filtered view: only the active buffer's diagnostics. Useful when you want :cnext-style navigation but limited to one file.

When ]d says "no more diagnostics in this buffer" and :cnext would jump to a different file, the model is deliberate: per-buffer navigation never surprises you by switching files; workspace navigation does so on purpose.

One buffer, multiple servers

A .cpp file with both clangd (semantics) and a custom linter bridge (style) attached. Each is its own actor; both publish diagnostics. lattice merges per feature:

FeatureWhat happens with two servers
DiagnosticsBoth lists shown in the gutter / inline overlay / :diagnostics buffer. The most severe wins per-line for the gutter glyph.
HoverBoth responses concatenated, each section labelled with its server_id.
Goto-definitionHigher-priority server's response wins. If empty, falls through to the next.
ReferencesUnion of all servers' results, deduped.
Code actionsPicker shows entries from each server, prefixed [server-id] so you can tell them apart.
FormattingSingle winner: highest-priority server with documentFormattingProvider. Avoids two formatters fighting.
RenameWorkspaceEdits from each server merged; conflicts (same range edited by two servers) resolve to the higher-priority one.

Server priority is configurable in lsp.toml:

[server.rust]
priority = 100  # default; higher wins ties

[server.clippy-bridge]
priority = 50

A crashed server affects only its own diagnostics + its own running task; other servers attached to the same buffer keep working.

One server, multiple workspaces

Open files from two unrelated Cargo workspaces. lattice spawns two rust-analyzer actors, one per workspace root. Each maintains its own indexed view; cross-workspace navigation is not yet supported (multi-root workspace folders are post-1.0).

Toggling features off

Every LSP feature has a sub-mode (M.6 cascade). The full list lives in lsp-mode.md; common ones:

  • :lsp-diagnostics-mode — hides gutter glyphs + inline underlines
  • :lsp-inlay-hint-mode — hides inlay-hint virtual text
  • :lsp-semantic-tokens-mode — reverts to tree-sitter highlighting
  • :lsp-document-highlight-mode — hides the cursor-symbol tint
  • :lsp-hover-mode — disables K

Toggling a sub-mode off leaves the server attached -- other features keep working. To detach entirely, use :lsp-mode (the umbrella). Re-toggling :lsp-mode twice (off→on) resets every sub-mode to active.