diff options
Diffstat (limited to 'docs/lsp.md')
| -rw-r--r-- | docs/lsp.md | 145 |
1 files changed, 132 insertions, 13 deletions
diff --git a/docs/lsp.md b/docs/lsp.md index b35c8eeb..4b2e5ba2 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -149,11 +149,10 @@ to end — the enum one level up (absolute row) and one level down (`inner/tint. 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` already follows for its own rows, and it is -spelled TWICE: `lsp.rel` here, and an inline `if` over the asking pane's -directory in `look.zig`'s `grep` (the `shown` computation) there. Same rule, -two implementations — they want to become one function, and `lsp.zig`'s own -comment on `rel` says so. +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 @@ -250,6 +249,118 @@ Known limitations, in the order you will meet them: - **`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. @@ -302,16 +413,18 @@ Verified against `helix-term/src/keymap/default.rs`, not from memory. | `gi` | implementation | | | `gr` | references | | | `SPC l k` | hover | opens `+Hover` | -| `SPC l r` | rename | tag input; applies current-file references in one undo step | +| `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 | | +| `=` | 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` @@ -340,12 +453,14 @@ 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: +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` | which ZLS, which stdlib (and whether it opens), what the backend answers and refuses, plus the 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 | +| `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 @@ -362,9 +477,13 @@ document — which matters precisely when the pane you are in is the problem. ## Writing a backend -`src/lsp/lsp.zig` is the seam. An implementation supplies three things and touches -nothing else (`backend_name` below is the SEAM's, not yours — it is a literal in -`lsp.zig` naming whichever backend was compiled in): +`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 |
