summaryrefslogtreecommitdiff
path: root/docs/lsp.md
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /docs/lsp.md
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'docs/lsp.md')
-rw-r--r--docs/lsp.md595
1 files changed, 58 insertions, 537 deletions
diff --git a/docs/lsp.md b/docs/lsp.md
index 4b2e5ba2..82586344 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -1,551 +1,72 @@
-# Language intelligence in pardes
+# Language intelligence
-Three things landed together, and only the first two are permanent:
+Native hosts, including detached sessions, run language queries on workers.
+Zig uses the linked ZLS analyser; other languages use the protocol client in
+`src/lsp/lsp_client.zig`. Web and board builds have no language backend.
-1. **An async execution model.** The core stays a state machine; slow work goes
- to a worker and comes back as an event.
-2. **A helix-exact keymap** for every LSP command.
-3. **A seam** (`src/lsp/lsp.zig`) with exactly one function behind it, so competing
- backends can be swapped, measured, and thrown away.
+`Lspinfo` (`SPC l i`) reports backend versions, server state, capabilities,
+recent timings and errors. `Lspwhy` (`SPC l w`) traces resolution at the cursor.
-## The async model
+## Commands
-There was none before this: every effect the core emitted was fire-and-forget
-(`spawn`, `write`, `save_file`) or instantaneous. A language query is the first
-thing pardes asks for that *answers later*, so it needed a request/response
-shape — and got the smallest one that works.
+| Keys | Action |
+|---|---|
+| `gd`, `gD`, `gy`, `gi`, `gr` | Definition, declaration, type, implementation, references |
+| `SPC l k` | Hover |
+| `SPC l r` | Rename |
+| `SPC l a` | Code action |
+| `SPC l h` | Select references |
+| `SPC l s`, `SPC l S` | Document and workspace symbols |
+| `SPC l d`, `SPC l D` | Document and workspace diagnostics |
+| `]d`, `[d`, `]D`, `[D` | Next, previous, last and first diagnostic |
+| `=` | Format |
+| Ctrl-click | Definition |
+| Insert-mode Tab after `.` | Candidate declarations |
+| `SPC l c`, `SPC l C` | Incoming and outgoing calls |
+| `SPC l t`, `SPC l T` | Supertypes and subtypes |
-```
-core shell worker
- | Effect .lsp{id,kind, | |
- | pane,offset,arg} | |
- |-------------------------->| |
- | | snapshot path + content |
- | |--------------------------->|
- | | | lsp.query(...)
- | | Event .lsp_resp{id,rows} |
- |<--------------------------|<---------------------------|
- | lspResponse -> atomic edit, jump, or results buffer |
-```
+A single goto result jumps directly; several results open a reusable search
+buffer. `n`/`N` select result rows and Enter opens them. Hover opens prose.
+Locations use one-based `path:line:column` or `path:line:column-endcolumn`.
+Paths below the requesting pane's directory are relative to that directory;
+other paths stay absolute.
-The shell already ran this exact pattern for pty readers, so the async part is
-about thirty lines per shell: `src/tty/tty.zig` uses `io.concurrent` + the
-vaxis loop queue, `src/gui/gui.zig` uses a detached thread (`lspThread`) + the
-mutex queue it already had. A FREESTANDING core compiles in no backend at all
-(`zls_backend = !freestanding_core` in `build.zig`), which is both the web
-shell and the ESP32-P4 object: there `lsp.supports` is empty, which makes
-`lspRequest` return before it emits, so the effect is never even raised.
-`web.zig` leaves the host's `pull_lsp` null and exports nothing for a
-response; a host that links a backend would add both.
+Completion lists declarations and inserts nothing. An unanswered Tab indents
+only if the cursor has not moved since the request. Multicursor Tab indents
+without starting a query.
-**In a DETACHED session every query answers EMPTY.**
-`src/detached/server.zig`'s vtable implements seventeen of `host.zig`'s
-twenty-one methods, and `pull_lsp` is one of the four it leaves null — a
-worker pool is precisely what its deliberately single-threaded loop does not
-have. A null method is NOT automatically a dropped effect: `perform` decides
-that per arm, and the `.lsp` arm's answer is to synthesise one on the spot —
-an `lsp_resp` Event with `rows = ""`, fed straight back into `update`. `.pipe`
-one arm below does the same, yielding
-`pipe_resp{ .success = false, .outputs = &.{} }`, so a null `pull_pipe` is a
-pipe REPORTED as failed rather than one that hangs. So `gd` jumps nowhere,
-`gr` finds no references, `SPC l r` renames nothing (an empty edit list parses
-as none) and Tab after a dot offers nothing — all of it indistinguishable from
-a backend that found nothing, which is exactly what the seam's "no rows is a
-legal answer" rule promises.
+Formatting and same-file rename apply as one undo transaction. A workspace
+rename spanning other files opens a preview; it does not partially apply the
+rename. The linked Zig analyser resolves current-file references and relative
+imports, but does not run the build runner to discover dependency modules.
-**Tab still indents — still, not always.** The empty response reaches
-`lspResponse`, whose `rows.len == 0` prong performs the indent the Tab prong
-skipped, but only while the cursor has not moved. That
-guard holds on the ordinary path because of `pump`'s order: `pull_wait_input`,
-then the queued events, then the effects, then render. The effect Tab emitted
-is performed after every keystroke that was ALREADY readable in the same
-round, since `pull_wait_input` applies a whole batch and not one event (the
-tty shell says so at the head of `waitInput` — "block for one event, then
-apply the whole pending batch" — and the daemon's poll loop drains every
-readable client `.event` straight into `core.update`). One keystroke per wake
-is the normal case and the indent lands in the same frame, before render. In a
-BURST where the key after Tab was readable in that same poll round, `cur_col`
-has moved by the time the prong runs, the guard fails, and the Tab really is
-eaten. The local shells have the same race over a wider window, so this is a
-property of the late-indent repair rather than of detaching.
+## Configuration and testing
-None of the detached core's four null methods silently drops a reachable
-effect. `pull_lsp` and
-`pull_pipe` have the fallbacks above; `pull_gpio_toggle` is
-`orelse return Error.NoPads` (`board_memory.zig`), which lands on the message
-row; `push_post_present` is a `pump` hook fired after presenting, and there is
-nothing to notify in a process with no screen. `push_fs_reply` was listed here
-too until the daemon began mounting its own `/dev/fuse`; it implements that one
-now, and `--fs` works in a detached session.
+`PARDES_LSP_RS`, `_C`, `_GO`, `_TS` and `_PY` override the server executable
+for each language. An empty value disables that server. `ZIG_LIB_DIR` overrides
+the stdlib path recorded at build time. `Lspinfo` shows the effective settings.
-Four rules make it safe:
+The protocol client keeps one child server per configured language, negotiates
+position encoding, and handles both push and pull diagnostics. Startup and
+request waits have deadlines; failed starts back off before retrying. Worker
+status messages reach the host event queue.
-- **The worker never touches the core.** Path, source, arg and root are copied
- into an `LspJob` before it starts — one per shell, in `src/tty/tty.zig` and
- `src/gui/gui.zig`. The user keeps typing while a
- query is in flight; a borrowed slice would be a use-after-free the length of
- one keystroke.
-- **One query in flight, identified by a monotonic id.** A second press bumps
- the id, which makes the older answer stale. The pending request also records
- the pane serial, so a closed-and-reused slot cannot accept its response.
-- **Mutating answers are revision-checked.** Rename records the file revision
- sent to the worker and applies nothing if the user edited before it answered.
-- **No rows is a legal answer.** A backend that cannot answer appends nothing,
- which is indistinguishable from a language server still starting up, and the
- core does nothing. There is no error path to render.
+`zig build lspprobe -- gd <file> <line>:<column>` queries the same backend from
+the command line. `zig build lspbench` measures it. Snapshot tests use a Zig
+mock server with deterministic answers while exercising the real client,
+including process startup, framing, edits and undo. `zig build fs-test` also
+checks language-worker delivery in TTY and detached sessions over 9P.
-## Results are `+Search` rows
+## Ownership
-Every backend renders into one format:
+`host_io.Lsp.Job` owns copies of the source, path, argument and root. Workers
+never read the live core. A request ID and pane serial reject obsolete replies;
+mutating replies also require the original file revision. Restore cancels
+owned work before replacing the core.
-```
-sub/file.zig:LINE:COL text under the asking window's dir
-sub/file.zig:LINE:COL-ENDCOL text
-/abs/path/elsewhere.zig:LINE:COL text anywhere else
-```
-
-1-based line and column. The second form carries the answer's
-RANGE where the protocol gave one on a single line (a token, a symbol's name),
-and a look on it SELECTS that span rather than parking at its first cell — so
-`gd` lands on the whole name, and a `gr` reference stepped to with `n`/`N` and
-opened with Enter arrives with the reference itself selected. This is the
-format `look.zig` already resolves, `runSearch` already produces and `n`/`N`
-already walk — so:
-
-- **one row from a goto** → jump straight there (`lookAt`)
-- **several rows** → an output buffer, which `n`/`N` walk: a step SELECTS a row
- and Enter opens it
-
-which means helix's multi-result picker required **no picker code at all**. The
-`+Search` buffer *is* the picker. Non-location answers (`hover`, `code_action`,
-`format`) open `+Hover`/`+Lsp` instead, which are prose and not places — `n`/`N`
-find nothing look-able in a documentation blurb and walk straight past it to the
-next pane on the ring.
-
-Rename is deliberately the one exception to rows as presentation. The backend
-emits `@edit START END` records through `lsp.edit()`, using half-open byte
-offsets into the exact `Req.source` snapshot it resolved. The core validates
-that every range is ordered, non-overlapping and in bounds, checks that the
-pane serial and file revision still match, then substitutes the requested name
-across all ranges with one allocation and one undo transaction. A malformed,
-stale, or empty response changes nothing. The current ZLS backend resolves and
-renames references in the **current file only**; it does not claim a workspace
-rename.
-
-**A path UNDER `Req.root` is written relative to it; everything else keeps its
-full absolute path** (`lsp.rel`). `Req.root` is the directory of the file the
-query was asked about — the window that generated the buffer — and a `gr` over
-one file was otherwise the same forty-character prefix repeated down the whole
-pane, with the part you came to read pushed off the right edge. The short form
-resolves because `Req.root` is also the directory the results buffer is *named*
-in (`output_pane.open`), and a look resolves a relative word against the
-directory of the pane it was clicked in — which is that buffer.
-
-Under, never "shorter": a hit outside the tree is **not** walked up to with
-`../`. An absolute path resolves from anywhere and says where it is; a `../..`
-chain says neither, and stops being true the moment the row is read anywhere but
-beside its own buffer. `test/snapshots/lsprelpath.snap` pins both directions end
-to end — the enum one level up (absolute row) and one level down (`inner/tint.zig`,
-a stripped path that still has a separator in it), each with the `n` step that
-selects it and the Enter that opens it, plus a right click.
-
-This is the rule `look.grep` follows for its own rows, and since the client
-landed it is spelled ONCE: look.zig's `grep` calls `lsp.rel` for its `shown`
-paths rather than keeping the inline twin this paragraph used to complain
-about.
-
-`completion` is the kind this shape changes the most. Every other editor answers
-a dot with a popup of NAMES to insert; a seam that returns locations cannot
-insert anything, so this one answers with the candidates' **declarations** —
-one row each, in the same `+Search` buffer, steppable with `n`:
-
-```
-path:LINE:COL-ENDCOL name the candidate's declaration line
-a.zig:2:5-13 verdigris verdigris,
-```
-
-The name comes first because that is the thing you would type — the row answers
-"what goes here" before "where does it come from", which is the order the
-question was asked in; every other kind here answers a WHERE, and for this one
-the location is the evidence rather than the answer. It is not padded into a
-column: the location in front of it is already ragged, so there is nothing to
-align to. That is a different and arguably better answer to "what goes here":
-you get the word AND you can read the definition rather than a list of words. It
-is the one location kind that does NOT jump on a single row, because with one
-candidate you still want to see the list rather than be teleported into it.
-
-A results buffer is REFILLED rather than reopened when the same kind is asked
-again — the rule `runSearch` always had, and which the language path was
-missing. It survived being missing while every query was a deliberate press
-(`gr` twice left two identical lists and you closed one); Tab after a dot is an
-ordinary typing keystroke, and measured, twenty of them stacked **fifteen**
-byte-identical `+Search` panes, crushed the file to one visible line, and then
-ran `freeSlot` out so the key was silently eaten for the rest of the session.
-Unlike a search the ARGUMENT is not part of the identity: a language query is
-asked about a different symbol every time with the same (usually empty) arg, so
-the kind is the unit.
-
-Making it work needed one trick. A completion is asked for exactly when the
-line is half-typed, and a half-typed line does not parse: `switch (e) { . }`
-loses the whole switch to the parser's error recovery, taking with it every
-ancestor an expected-type resolution needs. ZLS answers this with a private
-token scanner welded to its `*Server`. `lsp_zls.completionSource` instead makes
-the tree PARSE — it splices a placeholder in after the dot, in the six
-spellings a half-typed line can need — three shapes, each with and without a
-closer still hanging: a switch prong (`_p => {},` / `_p => {}, }`), an
-unterminated statement (`_p;` / `_p)`), and a bare identifier (`_p` / `_p }`)
-— and keeps the one that both makes
-the dot reachable in the tree and leaves the fewest parse errors. Everything
-after that is ZLS's ordinary public resolution over an ordinary tree.
-
-**A Tab the backend cannot answer still indents.** The keystroke has already
-diverted by the time "no rows" comes back, so `lspResponse` performs the indent
-the Tab prong skipped — on the condition that the cursor has not moved since,
-so nobody who kept typing gets four spaces landing behind their hands. Without
-that, a dot in a comment, in a string, or on a line nothing can be made of ate
-the keystroke outright. With several cursors Tab never diverts at all: a
-language query is a per-keystroke action inside a per-selection replay, so
-asking would stop the replay dead and collapse the multicursor.
-
-### What it costs, and what it cannot do
-
-Per press. **Both timings date from 2026-08-09**, change `lmlltvrx`, and have
-not been re-measured; the line count beside the first was refreshed once
-afterwards, on 2026-08-12 in change `vwtlskzr`, and `src/pardes.zig` is 16 466
-lines today. The ReleaseFast column is `zig build lspbench`, which is pinned to
-ReleaseFast in `build.zig` and always has been — so the Debug column came from
-running the installed editor by hand and the repo records no harness for it. A
-completion parses the buffer once per placeholder spelling it tries, so the
-first row scales with the file: re-run rather than trusting either number.
-
-| | ReleaseFast | Debug (what `zig build` installs) |
-|---|---|---|
-| a switch arm in `src/pardes.zig` (14.6k lines) | 8.8 ms | 87 ms |
-| `std.` — 91 candidates, each alias-resolved into the stdlib | 26 ms | 204 ms |
-
-It is a worker thread, so the editor does not block. On the TTY shell the
-second press of Tab then joins the first query on the UI thread —
-`old.cancel(s.io)` on the one in-flight future, and a backend that ignores
-cancellation means waiting out a query the user already abandoned
-(`src/tty/tty.zig`, the `lsp` vtable entry, whose own comment says so). The
-GUI shell does not join: it spawns another thread per request and lets the
-core's monotonic id make the older answer stale, so it pays memory instead of
-latency. Pre-existing and shared by every LSP kind — not this feature's to
-fix, but it is what a fast double-Tab feels like on a terminal.
-
-Known limitations, in the order you will meet them:
-
-- **`@This()` anywhere in a container makes the whole container unresolvable**,
- so `var list: std.ArrayList(u8) = .` — the most common decl literal in this
- codebase — answers nothing. This is not the completion filter: `hover` and a
- plain field access on the same struct return nothing either. It is the case a
- user hits first, and it is upstream of everything here.
-- **Only the break AT THE CURSOR is repaired.** Zig's error recovery runs
- forward, so an unrepaired break earlier in the file swallows the declaration
- the cursor is in and the answer is empty. While typing you normally have one
- broken spot, which is the case this works for.
-- **A dependency module** (`@import("vaxis")`) cannot be typed at all, for the
- same reason `gd` on `vaxis.init` finds nothing.
-- **`error.`** is not handled — the position context is `.error_access`, which
- no branch claims.
-
-
-## The protocol client: every other language
-
-`src/lsp/lsp_client.zig` is the second backend behind the same seam: a real
-LSP client — JSON-RPC 2.0, `Content-Length` frames — speaking to child
-processes. Nothing in it knows any single language; `specs` is a table of
-(binary, languageId, extensions, root markers), and rust-analyzer, clangd,
-gopls, typescript-language-server and pyright are rows in it. The seam asks
-each backend `speaks(path) and supports(kind)` in order, so `.zig` stays with
-the in-process analyser (cold is warm, no process) and everything else routes
-here. `SPC l i` prints both sections; `backend_name` is `zls-inproc+lsp-client`.
-
-**One server per spec, one reader thread per server, and the reader is not
-optional.** A real server TALKS: rust-analyzer streams `$/progress` for the
-whole minutes-long index of a big workspace, publishes diagnostics nobody
-asked for, and asks its own `workspace/configuration` questions mid-flight.
-The reader owns the read side of the socketpair, routes responses to the one
-waiting query (a mailbox under the connection's mutex), answers
-server-to-client requests so the server never blocks on us, feeds the
-diagnostics store, and narrates state changes through the STATUS SINK — a
-callback both native shells register at startup and post to their event
-queue, so "rust-analyzer: cargo check 88% 955/1083" lands on the same
-transient message row a save narrates into (`message.stamp`, verb `lsp`, on
-the ACTIVE pane — server state is session news, not a fact about the pane
-that asked). Chatty progress is throttled to one post per 150ms per server
-and deduplicated; state CHANGES (starting, ready, exited, errors) always
-land, and repeating the row already shown never does — which is also what
-makes the settled state deterministic for the snapshot goldens.
-
-Nothing may wedge the editor, and nothing healthy may be killed for being
-busy:
-
-- every write and every mailbox wait is deadline-bounded (8s handshake, 4s
- request); a query the server does not answer in time returns no rows and
- sends `$/cancelRequest`;
-- three CONSECUTIVE timeouts mean wedged and force a restart — but only
- while the server is idle. One with active `$/progress` (rust-analyzer
- mid-`cargo check` over a thousand crates) is demonstrably alive, already
- narrating its own excuse on the message row, and killing it would throw
- the index away right before it pays off. This rule exists because the
- first run against a thousand-crate workspace did exactly that;
-- a failed spawn or handshake is NOT a session disable: it backs off
- exponentially (10s doubling to 2min, reset by the next success), because
- the failure that taught this was a rustup shim deciding to download the
- project's whole pinned toolchain before launching the real server. Only a
- missing binary disables a spec, once, with a message saying which env var
- overrides it;
-- a server that dies is reaped by whoever saw it die (the reader on EOF,
- `shutdownIf` on a transport error), the fd is closed by the READER ALONE —
- `shutdown(2)` first, so a polled fd number is never recycled under a
- thread still watching it — and the next query respawns, generation-checked
- so a stale worker can neither adopt nor kill its successor's server.
-
-`PARDES_LSP_RS` / `_C` / `_GO` / `_TS` / `_PY` override each spec's binary
-(a path or a PATH name); the empty string disables the spec. The snapshot
-harness pins `_RS` to `test/lspmock.zig`'s deterministic mock and empties
-the rest, so `test/snapshots/lsp-client.snap` (gd across files, gr spans,
-n/Enter) and `lsp-client-edit.snap` (format apply, rename apply, one-step
-undo for each) drive the REAL client — spawn, handshake, reader, narration —
-against answers a golden can quote. `zig build lspprobe -- gd <file> <l>:<c>`
-is the same seam from the command line, for pointing at any real workspace;
-comma-separated kinds share one server so a big index is paid for once.
-
-The root is helix's `find_root` rule: walking up from the file, the TOP-MOST
-directory holding one of the spec's markers wins (a cargo workspace's root
-`Cargo.toml` beats the member crate's), the closest `.git` is the fallback,
-the asking directory the last resort. A second project in the same session
-becomes a workspace FOLDER when the server advertises support. Position
-encoding is negotiated to utf-8 and the server's ANSWER is believed; the
-utf-16 conversion is implemented in both directions for servers that refuse.
-Diagnostics PULL (`textDocument/diagnostic`, LSP 3.17) is preferred when the
-server advertises it — rust-analyzer does — and the push store fed by the
-reader answers otherwise, `]d` stepping either for free.
-
-### Mutating answers really mutate now
-
-The seam grew a second record form beside rename's `@edit`: `@put START END
-TEXT` carries a per-range replacement, percent-encoded onto the one line a
-record is allowed to be (`lsp.put`). The core decodes, validates (ordered,
-non-overlapping, in bounds, revision unchanged) and applies ALL records as
-one undo transaction, then says so on the message row ("formatted 1
-range(s)", "renamed 2 range(s)"). So:
-
-- `=` FORMATS, like helix — through the client it applies the server's
- TextEdits; through ZLS it applies one span covering everything `zig fmt`
- would change. The two non-edit answers stay prose in `+Lsp`: a file that
- does not parse, and (client-side) a server with no formatter.
-- `SPC l r` through the client applies a WorkspaceEdit that stays inside the
- asked-about file. One that spans OTHER files (a real workspace rename)
- arrives as location rows instead and opens as a PREVIEW list in the same
- buffer `gr` fills — applying a fraction of a workspace rename silently
- would be worse than either. The ZLS backend still resolves and renames
- current-file references via `@edit`, exactly as before.
-
-### Four kinds helix does not have
-
-The hierarchy kinds are two-step in the protocol (prepare at the cursor,
-then follow the item), are gated on the server capability so an old server
-costs zero round trips, and their answers are LOCATIONS — the one thing this
-seam renders for free. helix has no binding for any of the four (checked
-against helix-term/src/keymap/default.rs).
-
-| keys | kind | what the rows are |
-|---|---|---|
-| `SPC l c` | incoming_calls | one row per CALL SITE, under the caller's name |
-| `SPC l C` | outgoing_calls | the callees' declarations |
-| `SPC l t` | supertypes | the types this one extends/implements |
-| `SPC l T` | subtypes | the types that extend/implement this one |
-
-All four behave like `gr`: a list to walk with `n`/`N`, and a lone answer is
-a jump.
-
-## Which ZLS, and which stdlib
-
-Both are decided at build time, and `SPC l i` prints both.
-
-`build.zig.zon` pins ZLS to a COMMIT rather than a tag —
-`git+https://github.com/zigtools/zls#3e0d082084be43e36865136a138c1fe2023b33ca`,
-on the 0.16.x branch — because master requires Zig 0.17-dev and no tagged
-release both builds on 0.16 and exports the internals this backend calls.
-`build.zig` spells the same commit a second time, as the top-level
-`const zls_version = "0.16.1-dev+3e0d0820"`, and hands it to the ZLS package's
-own `-Dversion-string` and to `pardes_config.zls_version`. The duplication is
-unavoidable rather than sloppy — the semver half (`0.16.1-dev`) exists nowhere
-in the manifest — and it is load-bearing, because ZLS's build otherwise
-derives that string from `git describe`, which has nothing to read in a
-fetched package with no `.git`. The two are made to AGREE BY CONSTRUCTION: a
-`comptime` block right below the constant takes the short hash after the `+`,
-takes the pinned commit after the `#` in `zon.dependencies.zls.url`, and
-`@compileError`s unless the first is a prefix of the second. A `.zon` bump
-that forgets `build.zig` is therefore a build error, not a `SPC l i` naming a
-build nobody linked.
-
-`std` is the harder half. ZLS resolves `@import("std")` through `zig_lib_dir`
-and through nothing else, and this backend sets `zig_exe_path = null` on
-purpose — asking the `zig` binary is the subprocess the whole design exists to
-avoid. So `build.zig` bakes `b.graph.zig_lib_directory.path` into
-`pardes_config.zig_lib_dir`: the exact stdlib pardes itself was compiled
-against, which is what makes `gd` on `std.mem.count` land in the real
-`mem.zig`. `zigLibPath()` (`src/lsp/lsp_zls.zig`) reads `ZIG_LIB_DIR` from the
-environment FIRST and falls back to the baked path, so a user who moved the
-toolchain can point the backend at it without rebuilding. With no lib dir at
-all every `std` symbol is a silent miss — which is why `SPC l i` reports
-whether the directory OPENS rather than only which one was compiled in.
-
-That same `zig_exe_path = null` is the dependency-module limitation above.
-ZLS resolves a relative `.zig` path from the filesystem and `std` from the lib
-dir, but any other import name — every dependency in `build.zig.zon` — it can
-only answer by running `zig build --build-runner` to discover the module
-graph. With no zig binary that branch returns nothing, so `@import("vaxis")`
-is a silent miss by construction rather than by omission.
-
-## The keymap is helix's, exactly
-
-Verified against `helix-term/src/keymap/default.rs`, not from memory.
-
-| keys | command | notes |
-|---|---|---|
-| `gd` | definition | jumps on a single result, lists on several |
-| `gD` | declaration | |
-| `gy` | type definition | |
-| `gi` | implementation | |
-| `gr` | references | |
-| `SPC l k` | hover | opens `+Hover` |
-| `SPC l r` | rename | tag input; applies same-file edits in one undo step, PREVIEWS a multi-file WorkspaceEdit as rows |
-| `SPC l a` | code action | |
-| `SPC l h` | select references | |
-| `SPC l s` / `SPC l S` | document / workspace symbols | `S` takes a query |
-| `SPC l d` / `SPC l D` | document / workspace diagnostics | |
-| `]d` / `[d` | next / prev diagnostic | steps the list, asks for one if absent |
-| `]D` / `[D` | last / first diagnostic | |
-| `=` | format | applies the formatter's edits in one undo step |
-| `Ctrl`+left-click | definition | the mouse spelling of `gd` |
-| `Tab` in INSERT mode, right after a `.` | completion | what could go here, and where each of those is defined |
-| `SPC l c` / `SPC l C` | incoming / outgoing calls | beyond helix — see the client section |
-| `SPC l t` / `SPC l T` | supertypes / subtypes | beyond helix — see the client section |
-
-Tab is the one key here that is not helix's and not a goto. helix's `Tab`
-completes; pardes's shows you the CANDIDATES' DECLARATIONS in a `+Search`
-buffer and inserts nothing, because that is what a seam returning locations can
-honestly do — see below. It only diverts where an answer is possible: on a
-terminal, in an output buffer, or in a file the backend does not speak
-(`lsp.speaks`, which the core asks and the backend answers), Tab indents
-exactly as it always did. A Tab that silently does nothing would be worse than
-not having the feature. Nothing about the mode changes either — the pane is
-still in insert, so typing goes on and walking the answer with `n` means
-pressing `Esc` first, the same as for every other results buffer.
-
-Ctrl-click rides the ordinary left-click drag rather than firing on the press:
-a click does not place the modal cursor until RELEASE, so a query asked at
-press time would answer about wherever the cursor previously sat. A ctrl-DRAG
-still selects, and still asks about where it started.
-
-**The gotos are helix's exactly; the leader commands are helix's letters under
-an `l` prefix.** `g` and `[`/`]` had no conflicts — `gd/gD/gy/gi/gr` and
-`]d/[d` were all free, so they stay where helix puts them. The bare `<space>`
-letters were NOT free, and an earlier pass took them anyway, displacing `SPC d`
-(Del), `SPC k` (Kill), `SPC s?` (Dump/Restore) and `SPC h t` (Tutor). That is
-the wrong trade: those are pardes's most-pressed keys and predate the language
-work, whereas an LSP command is something you reach for deliberately and can
-afford one keystroke more. So every one of them keeps helix's own letter and
-gains the prefix — `<space>k` becomes `SPC l k`, `<space>d` becomes `SPC l d`
-— and nothing pardes had moved at all.
-
-Two more live in the same group because they belong to it, not to helix (the
-four hierarchy kinds above are also pardes's own — helix has no spelling for
-them):
-
-| keys | builtin | what it shows |
-|---|---|---|
-| `SPC l i` | `Lspinfo` | BOTH backends: which ZLS and which stdlib (and whether it opens); every protocol server's state, root, encoding and capabilities; and each side's last 24 queries with timings, row counts and **the errors `query` swallowed** |
-| `SPC l w` | `Lspwhy` | why the definition query at the cursor answers what it does — narrated by whichever backend the file routes to |
-
-These exist because of the seam's own contract: a backend never fails loudly,
-which is right for an editor — a thrown analyser must not take the process with
-it — but it makes a broken backend and a correct one that found nothing look
-identical. `Lspwhy` narrates the REAL resolution path (the position context is
-the analyser's own answer, threaded out through a trace) rather than
-re-deriving it beside the code, because a debug view that reimplements the
-logic is one that can disagree with it. `Lspinfo` answers from ANY pane,
-including one with no file, since it is about the backend rather than a
-document — which matters precisely when the pane you are in is the problem.
-
-`K` is **not** hover — in helix it is `keep_selections`. It was checked; do not
-"fix" it.
-
-## Writing a backend
-
-`src/lsp/lsp.zig` is the seam, and since the protocol client landed it holds a
-LIST of backends, asked in order: the first one that `speaks` the file's
-language and claims the kind in `supports` answers. An implementation supplies
-three things and touches nothing else (`backend_name` is the SEAM's, not
-yours — one literal in `lsp.zig` naming the compiled-in combination; a backend
-with unsolicited news to deliver may additionally accept the status sink, as
-`setStatusSink` shows):
-
-```zig
-pub fn query(gpa, arena, req: Req, out: *std.Io.Writer) void
-pub fn speaks(path: []const u8) bool
-pub const supports: std.EnumSet(Kind)
-pub const backend_name = "..."
-```
-
-`supports` says what a backend can do; `speaks` says what it can do it TO, and
-exists for the one key that must not be eaten when the answer is no — see the
-Tab note above.
-
-`query` runs on a worker thread with no access to the core — everything it may
-read is in `req` (`path`, `source` (NUL-terminated), `offset`, `arg`, `root`).
-`out` is a plain `std.Io.Writer`: the shell owns the buffer behind it (an
-`Io.Writer.Allocating`), so a backend never allocates the result, never frees
-it, and cannot get the allocator wrong. `arena` is freed wholesale on return;
-`gpa` is for a backend's own scratch. Use `lsp.row()` to emit a location,
-`lsp.spanRow()` for one that carries the RANGE it matched (the
-`path:LINE:COL-ENDCOL` form above), `lsp.rel()` to spell a path against
-`req.root`, `lsp.lineCol()` to convert an offset, and `lsp.edit()` for each
-half-open range of a rename response. Location
-rows are byte-identical across backends; rename ranges are consumed by the core
-and never rendered. `rel` allocates nothing — it returns a slice of what you
-hand it.
-
-## How the implementations are judged
-
-`zig build lspbench` — same harness, same corpus, same 22 probes, every backend.
-The corpus is pardes's own `src/`, plus `test/lspfixture/`: five of the probes
-point at fixtures rather than at real source. Three of them do because on
-clean, already formatted code the correct answer to `diagnostics`,
-`workspace_diagnostics` and `format` is nothing, and that is indistinguishable
-from a backend that has neither — `broken.zig` carries an unused local and a
-misformatted fn, so all three have real work. The other two are the half-typed
-dot, whose two shapes are `dotcomplete.zig` (a switch prong that parses
-everywhere but at the dot) and `dothalf.zig` (a line also missing its
-terminator). The 22 probes cover 15 of the 17 `lsp.Kind`s
-— `definition` three times, `document_symbols` twice, `completion` five times,
-and the two introspection kinds (`status`, `explain`) not at all.
-
-- **Feature completeness.** Which kinds return rows, and whether the rows
- contain what they should. The harness trusts *results*, not the `supports`
- flag: a kind claimed but returning nothing is reported as `CLAIMED-EMPTY`,
- and a kind that answers without claiming is `unclaimed-works`. Correctness
- is a substring the rows must contain, so returning a confident wrong location
- scores worse than returning nothing.
-
-- **Latency.** `cold` (first query, index construction included) and `warm`
- (median of 20). They differ by orders of magnitude for an indexing backend
- and both matter: cold is what the first keypress costs, warm is what every
- one after it costs.
-- **Memory.** Peak RSS delta (`VmHWM`), so a backend that frees its index
- before returning still pays for having built it.
-- **Lines of code.** Not measured by the harness — it is `jj diff --stat`
- against the base commit. Less is better, and vendoring a library is not free
- but is charged in build time and dependency surface rather than in lines we
- maintain.
-
-The user-visible rename contract is also pinned through the actual TTY,
-leader prompt, worker and ZLS backend by `test/snapshots/lsp-rename.snap`: both
-resolved occurrences change, a shadowed local does not, and undo/redo treats
-the response as one transaction.
-
-Run `zig build lspbench -- --json` for machine-readable output.
+Backends implement `query`, `speaks` and `supports` in `src/lsp/`. The first
+backend supporting the file and query answers; status queries visit all of
+them. Results are written to the caller's writer. Use `lsp.row` or `spanRow`
+for locations, `edit` for rename ranges, and `put` for replacement text.
+Edit records use half-open byte offsets into the request's source snapshot.
+The core validates the complete response before applying it.