docs/internal/lsp.md
Purpose: document how Fresh embeds a full Language Server Protocol client — multi-server routing, the queuing/concurrency model, async result flow, diagnostics-as-markers, completion merging, and the feature set with its concessions vs. a full LSP client.
Scope: the LSP service layer (the LSP manager, the async client, diagnostics), the completion service, and the app-level glue (LSP actions, requests, event notification, status, diagnostic navigation, hover, dabbrev). The source is authoritative; several of the older internal design notes referenced here are now implemented or partially superseded (see end).
Three layers, each on a different thread/runtime:
Vec of server handles — not a per-language map. This Vec-of-handles is the multi-server data model; routing is by (language, feature) rather than by a single per-language slot.An older module-header diagram still draws the "one handle per language" model; the real handles field is a flat Vec to support multiple servers per language (the diagram is stale, the code is multi-server).
Fresh implements the array-of-objects config plus multi-handle Vec recommendation from the multi-LSP design note. That note was a research draft; the feature is now shipped.
LspFeature splits features into two routing classes:
This matches the Helix model. Vec order = priority for exclusive features. There is no fallback-on-null: if the first eligible server returns empty for an exclusive feature, that is the final answer (the deliberate "no fallback initially" choice).
The feature filter is All | Only(set) | Except(set), built from each server config's only/except feature lists. An allows(feature) check gates whether a server is eligible. Dispatch helpers on the manager:
Crucially, a handle reports no capabilities before the server is initialized. This is the load-bearing fix from the request-queuing plan: uninitialized servers are invisible to routing, so a request never lands on a server whose capabilities are unknown. No separate "pending handles" map is needed.
The request layer wraps the manager helpers with didOpen-before-request guarantees:
didOpen, then calls the caller's closure with the first eligible handle.didOpen to every handle not yet recorded as having opened the buffer.LSP requires didOpen before any document request. Fresh tracks this per buffer, per server instance:
didOpen for that buffer. Before a request, the dispatch helper sends didOpen to any handle whose id is absent, then records the id. This naturally handles multi-server and restart cases.didOpen on open/spawn, didChange on edit, didClose on close/disable, didSave with full text on save.didChange increments stay consistent.didChange issued shortly after didOpen waits out the remainder of a short grace window before sending, tracked in a pending-opens set. This avoids servers that race their own open processing.Each server spawns one LSP task, which in turn runs:
Writes to stdin are serialized through a shared async mutex over the child's stdin (used by both the command loop and the reader task) so JSON-RPC frames never interleave.
The design problem: a server handle exists before initialize completes; requests sent in that window hit a server of unknown capabilities, and empty/error responses get mistaken for "nothing found," poisoning a "request already sent" flag.
The shipped solution is a gate-and-retry model, not a full request queue:
didOpen/didChange/didClose/didSave received before init are pushed to a pending-commands buffer and replayed after the initialize handshake, gated on an initialized flag.The "queue everything" model (as in VS Code) was explicitly rejected: Fresh's initialized handler already fires the right follow-ups, so the simpler gate-and-retry achieves zero-loss without a pending-feature-request queue.
Debouncing is cost-proportional (more expensive features wait longer):
$/cancelRequest to the server. Editor-side cancellation maps an editor request id to the LSP request id via an active-requests map, driven by a cancel command. Completions and code actions also cancel superseded in-flight requests on the main loop to avoid stale merges (the "clear previous pending set" guard).The task sends async messages over a bridge channel which the main loop drains. Variants cover initialization, status updates, completion, hover, goto-definition, references, code actions and resolution, formatting, rename and prepare-rename, semantic tokens, folding ranges, inlay hints, diagnostics (published and pulled), progress, apply-edit, server-initiated requests, and errors. The diagnostics variant carries the server name so per-server sets stay distinct. Each is consumed by a response handler that validates the request id (rejecting stale responses) before touching UI.
The stdout reader answers server-initiated requests directly, without going through the command loop — this is what makes nested executeCommand → applyEdit deadlock-free:
workspace/applyEdit → relays an apply-edit message to the main loop, replies with an applied flag.workspace/configuration → answers from the configured initialization options.client/registerCapability / unregisterCapability → mutates the capability snapshot (lets servers register diagnostics dynamically).workspace/{diagnostic,inlayHint,semanticTokens}/refresh → triggers re-pulls.window/workDoneProgress/create → acked.Spawning is funneled through a single throttle/decision point, used by automatic spawn, manual restart, and pending-restart processing.
false for most language servers. LSP must be started manually via the command palette unless configured otherwise. This is a deliberate resource choice but is the root of a known finding (a dormant LSP being weakly surfaced).Root detection walks upward from the file's directory looking for any configured marker, returning the first match or the file's parent (never $HOME/cwd). Resolution priority: plugin-set per-language root URIs → marker walk from the file → global root-URI fallback. Root markers are a real per-server config field, so different servers for one language can resolve different roots (the monorepo case).
.h problemLanguage detection resolves by exact filename → glob → extension. The .h→C-vs-C++ ambiguity is handled: a .h is promoted from c to cpp when it has C++ sibling sources or an ancestor compile_commands.json.
Diagnostics are stored two ways:
A content-hash cache keyed by file path skips overlay rebuilds when diagnostics are unchanged on a keystroke; it is invalidated on edit and on theme change.
Navigation: next/previous-error commands read overlay positions in the diagnostic namespace — one finds the first diagnostic after the cursor (wrapping), the other the reverse, both showing the message in the status bar.
Hover fusion: the hover response handler composes diagnostic lines for the position by filtering stored diagnostics for the buffer's URI and selecting those whose range overlaps the hover position. Matching diagnostics are rendered (severity glyph + label + source + message) above a separator, then the hover body. An empty hover still opens the popup if a diagnostic is present.
The completion stack is a separate service from LSP, into which LSP feeds as one async provider.
The completion service owns a list of boxed providers and a pending-async list. Built-in providers registered at construction: a buffer-word provider and a dabbrev provider. The LSP provider and TypeScript-plugin providers register dynamically. Each provider declares an id, an enabled check, a provide method, and a priority. The priority convention orders LSP first, then ctags/index, then buffer words, then dabbrev.
Provide returns either ready candidates or a pending request id. LSP is the canonical pending source — its results arrive asynchronously and are fed back through a supply-async-results entry point.
Implemented:
| Feature | Status / notes |
|---|---|
| Completion (+ resolve) | Multi-server merged; resolve applies additional text edits (auto-imports) on accept |
| Hover (+ diagnostic fusion) | Exclusive; fuses overlapping diagnostics |
| Go to definition / implementation | Exclusive; jumps to first location |
| Find references | Exclusive; results delivered to a results panel via a plugin hook |
| Rename (+ prepareRename) | Exclusive; prepareRename pre-validates when advertised |
| Code actions (+ resolve, executeCommand, applyEdit) | Merged across servers, server-attributed; full three-way dispatch — edit / command / resolve-then-execute |
| Signature help | Exclusive |
| Diagnostics (publish + pull) | Per-server tracked, overlay markers, navigation commands, hover fusion |
| Inlay hints | Exclusive; rendered as virtual text |
| Semantic tokens (full / delta / range) | Exclusive; range-debounced |
| Folding ranges | Exclusive |
| Document formatting / range formatting | Exclusive; applies edits |
workspace/applyEdit with version checking + resource ops | Handles create/rename/delete file and rejects stale-version document edits |
Progress ($/progress) | Relayed to status bar (see gap below) |
| Plugin-buffer LSP | A setup path writes a temp .ts + tsconfig.json + type-declaration file so unnamed plugin buffers get TS intelligence |
Concessions / gaps vs. a full LSP client:
$/progress may not render during some indexing sessions: the relay exists end-to-end (progress message → status bar) but did not surface in observed runs; classified as a bug in an existing feature, not a missing one.window/showMessageRequest, window/showDocument, on-type formatting, linked editing, selection range, workspace/didChangeWatchedFiles, and file-operation events (will-create/rename/delete). Treat these as PLANNED; LspFeature lists document and workspace symbols as merged classes, so the routing slots exist even where request wiring may be partial.(language, feature) (§2).Fresh runs a real multi-server LSP client: per language you can configure N servers, each with only/except feature routing; merged features (diagnostics, completion, code actions, symbols) fan out to all eligible servers and combine, while exclusive features (hover, definition, format, rename, …) take the first eligible server by config order. Each server is one tokio task with a stdin-serialized writer and an independent stdout reader that answers server→client requests (applyEdit, configuration, capability registration) directly to avoid nested-request deadlock; feature requests spawn as independent tasks so one slow request can't block the server. Results return to the main thread as async messages drained once per tick.
The concurrency story is "gate and retry," not "queue everything": notifications are queued in the task and replayed after initialize, while feature requests are simply not routed until a server reports capabilities post-init, with editor-initiated requests re-issued on initialization and user-initiated ones re-triggered naturally. Diagnostics are stored both per-URI (truth) and as marker-anchored overlays (display), feeding hover fusion and error navigation. Completion is a provider framework where LSP is one async source merged with dabbrev/buffer-words/plugins by priority-then-score with dedup. The main concessions vs. a full client are auto-start-off-by-default, no null-fallback for exclusive features, references-as-exclusive, and a set of still-planned navigation/symbol/file-watch features.
.ts + tsconfig + type-declaration approach is implemented as the plugin-dev LSP setup path..h→C++ promotion are done.