# 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.