diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/lsp.md | 146 |
1 files changed, 146 insertions, 0 deletions
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. |
