From f43c1e11b44e2464f0bb0b635e0abcaf3c717e23 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 28 Jul 2026 23:30:29 -0300 Subject: LSP seam: async execution model, helix keymap, evaluation harness The base every language backend plugs into. Three parts: ASYNC. The core had no request/response shape - every effect was fire-and-forget or instantaneous. A language query is the first thing that answers later, so: Effect .lsp -> shell worker -> Event .lsp_resp. tty.zig uses io.concurrent + the vaxis queue, gui.zig a detached thread + the mutex queue it already had for ptys; web no-ops it. The worker never touches the core (path/source/arg are snapshotted into an LspJob), one query in flight identified by a monotonic id so a second press makes the first answer stale, and no rows is a legal answer. KEYMAP. Helix's, verified against its default.rs rather than recalled. gd/gD/gy/gi/gr and ]d/[d had no conflicts. The SPC letters did, so pardes's own builtins moved instead of helix's: Kill k->q, Del d->wc (closing a pane is a window op, and c is helix's own close), Dump/Restore s?->f?, Tutor ht->T. A three-exception muscle-memory map is not a map. RESULTS ARE +SEARCH ROWS. path:LINE:COL text, absolute. That is what look.zig resolves and n/N step, so one row from a goto jumps and several open a buffer - helix's multi-result picker needed no picker code. Backends supply exactly one function (lsp.query) plus a supports set and a name; the base has none on purpose. zig build lspbench scores them on the same corpus: feature matrix (trusting results, not the supports flag - a claimed-but-empty kind is reported as a false claim), cold and warm latency, peak RSS. Two snapshot scripts moved. leader.snap encoded the old key paths. chordcut.snap's last two steps clicked column 5, which lands on a FILE pane, so 'key c-b' toggled nothing and the typed text was being read as normal-mode keys - the golden recorded no TTY pane and no cat -v output anywhere. Pointing them at an actual shell makes both steps assert what their comments claim, and the tty paste chord is now covered for the first time. --- docs/lsp.md | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/lsp.md (limited to 'docs') diff --git a/docs/lsp.md b/docs/lsp.md new file mode 100644 index 00000000..987e901c --- /dev/null +++ b/docs/lsp.md @@ -0,0 +1,146 @@ +# Language intelligence in pardes + +Three things landed together, and only the first two are permanent: + +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.zig`) with exactly one function behind it, so competing + backends can be swapped, measured, and thrown away. + +## The async model + +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. + +``` +core shell worker + | Effect .lsp{id,kind, | | + | pane,offset,arg} | | + |-------------------------->| | + | | snapshot path + content | + | |--------------------------->| + | | | lsp.query(...) + | | Event .lsp_resp{id,rows}| + |<--------------------------|<---------------------------| + | lspResponse -> jump, or open a results buffer | +``` + +The shell already ran this exact pattern for pty readers, so the async part is +about thirty lines per shell: `tty.zig` uses `io.concurrent` + the vaxis loop +queue, `gui.zig` uses a detached thread + the mutex queue it already had. The +web shell has no threads and no-ops the effect. + +Three rules make it safe: + +- **The worker never touches the core.** Path, source, arg and root are copied + into an `LspJob` before it starts (`tty.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 — `lspResponse` drops any id it is + not waiting for. This is also what makes a closed pane safe. +- **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. + +## Results are `+Search` rows + +Every backend renders into one format: + +``` +/abs/path/to/file.zig:LINE:COL text +``` + +1-based line and column, absolute path. This is the format `look.zig` already +resolves, `runSearch` already produces and `n`/`N` already step — so: + +- **one row from a goto** → jump straight there (`actOnSelection`) +- **several rows** → an output buffer, which `n`/`N` walk + +which means helix's multi-result picker required **no picker code at all**. The +`+Search` buffer *is* the picker. Kinds whose answer is prose rather than +locations (`hover`, `code_action`, `format`, `rename`) open `+Hover`/`+Lsp` +instead and do not arm the stepper — `n` over a documentation blurb would step +to nowhere. + +## 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 k` | hover | opens `+Hover` | +| `SPC r` | rename | tag input, like Find/Grep | +| `SPC a` | code action | | +| `SPC h` | select references | | +| `SPC s` / `SPC S` | document / workspace symbols | `S` takes a query | +| `SPC d` / `SPC D` | document / workspace diagnostics | | +| `]d` / `[d` | next / prev diagnostic | steps the list, asks for one if absent | +| `]D` / `[D` | last / first diagnostic | | +| `=` | format | | + +`g` and `[`/`]` had **no conflicts** — `gd/gD/gy/gi/gr` and `]d/[d` were all +free. The `SPC` letters were not, so pardes's own builtins moved out of helix's +way rather than the reverse: + +| builtin | was | now | why | +|---|---|---|---| +| Kill | `SPC k` | `SPC q` | `k` is hover; `q` is quit everywhere else | +| Del | `SPC d` | `SPC w c` | `d` is diagnostics; closing a pane IS a window op, and `c` is helix's own spelling for close in its window mode | +| Dump | `SPC s d` | `SPC f d` | `s` is symbols; writing a session file is a file operation | +| Restore | `SPC s r` | `SPC f r` | same | +| Tutor | `SPC h t` | `SPC T` | frees `h` for select-references | + +A three-exception muscle-memory map is not a map. That is the whole argument. + +`K` is **not** hover — in helix it is `keep_selections`. It was checked; do not +"fix" it. + +## Writing a backend + +`src/lsp.zig` is the seam. An implementation supplies three things and touches +nothing else: + +```zig +pub fn query(gpa, arena, req: Req, out: *std.ArrayList(u8)) void +pub const supports: std.EnumSet(Kind) +pub const backend_name = "..." +``` + +`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`). +`arena` is freed wholesale on return; `gpa` owns only what goes into `out`. +Use `lsp.row()` to emit a location and `lsp.lineCol()` to convert an offset, +so every backend's rows are byte-identical in shape. + +## How the implementations are judged + +`zig build lspbench` — same harness, same corpus (pardes's own `src/`), same 17 +probes, every backend. + +- **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. + +Run `zig build lspbench -- --json` for machine-readable output. -- cgit v1.3