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 +:diagnosticsroute 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
| Feature | What it does |
|---|---|
| Diagnostics | Errors, 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. |
| Hover | Press K on a symbol to see its type signature, doc comment, or runtime info in a popup. |
| Go-to-definition | gd 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 references | gr 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. |
| Completion | Triggered 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 help | Function-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 highlight | Parking the cursor on an identifier paints a soft bg tint on every occurrence (read / write / text variants). Gated by lsp-document-highlight-mode. |
| Inlay hints | Type annotations, parameter names, lifetime hints rendered as italic dim virtual text. Gated by lsp-inlay-hint-mode (toggle with :lsp-inlay-hint-mode). |
| Semantic tokens | LSP-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 links | gx 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. |
| Progress | Server $/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:
| Language | Binary | Install |
|---|---|---|
| Rust | rust-analyzer | rustup component add rust-analyzer |
| Python | pyright-langserver | npm install -g pyright |
| Go | gopls | go install golang.org/x/tools/gopls@latest |
| TypeScript / JavaScript | typescript-language-server | npm install -g typescript-language-server typescript |
| C / C++ | clangd | apt install clangd / brew install llvm |
| Lua | lua-language-server | brew 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:
- Detects the language from the file extension (and a future content-pattern fallback for shebang scripts).
- 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. - Spawns the server if no actor is already running for
(workspace, language). The server'sinitialize/initializedhandshake completes before the buffer is considered "attached". - Sends
didOpenwith 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.
Navigation
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
| Keystroke | Meaning |
|---|---|
K | Show 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/Nrepeat, andgg/G-style motion pushes onto it. - Tag stack (
<C-t>to pop) -- LIFO chain ofgd/gD/gy/gIdrill-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.).
| Chord | LSP method | What it asks |
|---|---|---|
gd | textDocument/definition | Where is the symbol defined? |
gD | textDocument/declaration | Where is the forward declaration / extern? |
gy | textDocument/typeDefinition | Where is the type of the expression defined? |
gI | textDocument/implementation | Where are the implementations of this trait / interface? Lowercase gi is reserved for vim's "go to last insert position". |
Lists
| Keystroke / command | Picker over |
|---|---|
gr | textDocument/references -- every call site of the symbol under cursor (include_declaration: true). |
:lsp-symbols | textDocument/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. |
:diagnostics | Every workspace diagnostic across attached servers; severity in marginalia. |
:complete | LSP 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
| Command | Effect |
|---|---|
:format (:fmt) | textDocument/formatting -- whole buffer; highest-priority server with the provider; one undo unit. |
:format-range | textDocument/rangeFormatting over the active Visual selection (or whole buffer when not in Visual). |
:signature-help | textDocument/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
+Nwhen 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.inline—off,cursor-line(default), orall(every viewport line; opt-in).ui.diagnostics.inline-min-severity—error,warning,info, orhint(default; show everything). Only diagnostics at or above this level count toward the summary.
:diagnosticspicker — 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 / command | Meaning |
|---|---|
]d | Jump to the next diagnostic in the active buffer (wraps), echoing its message. |
[d | Jump to the previous diagnostic (wraps), echoing its message. |
gl | Open a cursor-anchored popup listing every diagnostic on the cursor line (severity glyph, message, source/code, related-info count). |
:diagnostics | Open the workspace diagnostics picker. |
:diag-clear | Drop the renderer's overlay for the active buffer (server may republish). |
Diagnostics are separate from the error list.
:next-error/:cnextwalk the error list (compiler / tool output), not diagnostics — use]d/[d/:diagnosticsfor 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/notifyreturns immediately; the editor's input pipeline never awaits a server response synchronously. didChangeis debounced. Every keystroke commits one edit but doesn't emitdidChangeuntil ~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+ thePendingresolves 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
ActorGoneerror.
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
| Buffer | Contents | Open 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:
| Level | What it captures |
|---|---|
error | Unrecoverable failures (server died, framing rejected, decode totally broken). |
warn | Recoverable problems: stderr lines, unexpected protocol behaviour, malformed payloads dropped. |
info | Default. Lifecycle milestones (handshake done, server attached). |
debug | Per-message detail: publishDiagnostics summaries, $/progress events, telemetry. |
trace | JSON-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
| Command | Effect |
|---|---|
:lsp-log | Open *lsp*. |
:lsp-log <server> | Open *lsp:<server>* (e.g. :lsp-log rust). |
:lsp-trace <server> | Toggle JSON-RPC trace + open the trace buffer. |
:lsp-status | Show 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 ./latticesee the same events on stderr as in the buffer views. - Custom
tracing_subscribersetups (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."
:lsp-log rust-- look for handshake errors or "server request unhandled" entries.:lsp-statusshows the server's advertised capability set. IfhoverProviderisn't there, the server doesn't support it.:lsp-trace rust-- watch for→ Request method=textDocument/hoverthen the matching← Response id=.... A request without a response means the server is stuck.
"Diagnostics are stale."
:lsp-trace rustthen look for← Notification method=textDocument/publishDiagnostics. Compare itsversionfield against lattice'sDocSyncversion -- stale publishes are correctly dropped.*lsp:rust*showspublishDiagnostics: N diag(s) for <uri>summaries -- spot when the server stopped reporting.
"Server keeps crashing."
*lsp:rust*has stderr lines (warn-level) and lifecycleread_loop terminatingerrors. Server stderr captures the panic / exit reason verbatim.:lsp-statusshows restart counts.- The supervisor restarts with exponential backoff (100ms → 5s) and re-issues
didOpenfor every attached buffer.
"Editor feels slow when LSP attached."
- 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-lspnumbers. *lsp:rust*may show a flood of$/progress-- a runaway indexer.- 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
- Check the server's stderr in the log — lattice emits each line as a tracing warning with the
server_idfield.RUST_LOG=lattice_lsp=debugsurfaces them at the terminal. - 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, editlsp.tomlto setroot_markersexplicitly. - 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/[dwalk diagnostics in the active buffer only (wraps);:diagnosticsopens 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-errornever touches the LSP diagnostic list, and an empty error list echoesno error listrather 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:
| Feature | What happens with two servers |
|---|---|
| Diagnostics | Both lists shown in the gutter / inline overlay / :diagnostics buffer. The most severe wins per-line for the gutter glyph. |
| Hover | Both responses concatenated, each section labelled with its server_id. |
| Goto-definition | Higher-priority server's response wins. If empty, falls through to the next. |
| References | Union of all servers' results, deduped. |
| Code actions | Picker shows entries from each server, prefixed [server-id] so you can tell them apart. |
| Formatting | Single winner: highest-priority server with documentFormattingProvider. Avoids two formatters fighting. |
| Rename | WorkspaceEdits 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— disablesK
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.
Related
folding.md— fold engine; LSP fold ranges merge in viaFoldMethod::Lsp(4.4).../dev/notes/lsp-features.md— feature matrix.../dev/architecture/lsp-architecture.md— developer-facing architecture.../dev/architecture/design.md#54-lsp-subsystem§5.4 — canonical design.