summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/lsp.md146
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.