diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-28 23:30:29 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | f43c1e11b44e2464f0bb0b635e0abcaf3c717e23 (patch) | |
| tree | ddc5e5528ebb59f72d30bef49522f814578e4fce /docs | |
| parent | 65b207c3392c75eac3f2b18a266a6482d8345df1 (diff) | |
| download | pardes-f43c1e11b44e2464f0bb0b635e0abcaf3c717e23.tar.gz pardes-f43c1e11b44e2464f0bb0b635e0abcaf3c717e23.zip | |
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.
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. |
