summaryrefslogtreecommitdiff
path: root/docs/lsp.md
blob: 4b2e5ba266b93886baffc06ca145f33a80220ed7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
# 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/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 -> atomic edit, jump, or results buffer   |
```

The shell already ran this exact pattern for pty readers, so the async part is
about thirty lines per shell: `src/tty/tty.zig` uses `io.concurrent` + the
vaxis loop queue, `src/gui/gui.zig` uses a detached thread (`lspThread`) + the
mutex queue it already had. A FREESTANDING core compiles in no backend at all
(`zls_backend = !freestanding_core` in `build.zig`), which is both the web
shell and the ESP32-P4 object: there `lsp.supports` is empty, which makes
`lspRequest` return before it emits, so the effect is never even raised.
`web.zig` leaves the host's `pull_lsp` null and exports nothing for a
response; a host that links a backend would add both.

**In a DETACHED session every query answers EMPTY.**
`src/detached/server.zig`'s vtable implements seventeen of `host.zig`'s
twenty-one methods, and `pull_lsp` is one of the four it leaves null — a
worker pool is precisely what its deliberately single-threaded loop does not
have. A null method is NOT automatically a dropped effect: `perform` decides
that per arm, and the `.lsp` arm's answer is to synthesise one on the spot —
an `lsp_resp` Event with `rows = ""`, fed straight back into `update`. `.pipe`
one arm below does the same, yielding
`pipe_resp{ .success = false, .outputs = &.{} }`, so a null `pull_pipe` is a
pipe REPORTED as failed rather than one that hangs. So `gd` jumps nowhere,
`gr` finds no references, `SPC l r` renames nothing (an empty edit list parses
as none) and Tab after a dot offers nothing — all of it indistinguishable from
a backend that found nothing, which is exactly what the seam's "no rows is a
legal answer" rule promises.

**Tab still indents — still, not always.** The empty response reaches
`lspResponse`, whose `rows.len == 0` prong performs the indent the Tab prong
skipped, but only while the cursor has not moved. That
guard holds on the ordinary path because of `pump`'s order: `pull_wait_input`,
then the queued events, then the effects, then render. The effect Tab emitted
is performed after every keystroke that was ALREADY readable in the same
round, since `pull_wait_input` applies a whole batch and not one event (the
tty shell says so at the head of `waitInput` — "block for one event, then
apply the whole pending batch" — and the daemon's poll loop drains every
readable client `.event` straight into `core.update`). One keystroke per wake
is the normal case and the indent lands in the same frame, before render. In a
BURST where the key after Tab was readable in that same poll round, `cur_col`
has moved by the time the prong runs, the guard fails, and the Tab really is
eaten. The local shells have the same race over a wider window, so this is a
property of the late-indent repair rather than of detaching.

None of the detached core's four null methods silently drops a reachable
effect. `pull_lsp` and
`pull_pipe` have the fallbacks above; `pull_gpio_toggle` is
`orelse return Error.NoPads` (`board_memory.zig`), which lands on the message
row; `push_post_present` is a `pump` hook fired after presenting, and there is
nothing to notify in a process with no screen. `push_fs_reply` was listed here
too until the daemon began mounting its own `/dev/fuse`; it implements that one
now, and `--fs` works in a detached session.

Four rules make it safe:

- **The worker never touches the core.** Path, source, arg and root are copied
  into an `LspJob` before it starts — one per shell, in `src/tty/tty.zig` and
  `src/gui/gui.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. The pending request also records
  the pane serial, so a closed-and-reused slot cannot accept its response.
- **Mutating answers are revision-checked.** Rename records the file revision
  sent to the worker and applies nothing if the user edited before it answered.
- **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:

```
sub/file.zig:LINE:COL                    text     under the asking window's dir
sub/file.zig:LINE:COL-ENDCOL             text
/abs/path/elsewhere.zig:LINE:COL         text     anywhere else
```

1-based line and column. The second form carries the answer's
RANGE where the protocol gave one on a single line (a token, a symbol's name),
and a look on it SELECTS that span rather than parking at its first cell — so
`gd` lands on the whole name, and a `gr` reference stepped to with `n`/`N` and
opened with Enter arrives with the reference itself selected. This is the
format `look.zig` already resolves, `runSearch` already produces and `n`/`N`
already walk — so:

- **one row from a goto** → jump straight there (`lookAt`)
- **several rows** → an output buffer, which `n`/`N` walk: a step SELECTS a row
  and Enter opens it

which means helix's multi-result picker required **no picker code at all**. The
`+Search` buffer *is* the picker. Non-location answers (`hover`, `code_action`,
`format`) open `+Hover`/`+Lsp` instead, which are prose and not places — `n`/`N`
find nothing look-able in a documentation blurb and walk straight past it to the
next pane on the ring.

Rename is deliberately the one exception to rows as presentation. The backend
emits `@edit START END` records through `lsp.edit()`, using half-open byte
offsets into the exact `Req.source` snapshot it resolved. The core validates
that every range is ordered, non-overlapping and in bounds, checks that the
pane serial and file revision still match, then substitutes the requested name
across all ranges with one allocation and one undo transaction. A malformed,
stale, or empty response changes nothing. The current ZLS backend resolves and
renames references in the **current file only**; it does not claim a workspace
rename.

**A path UNDER `Req.root` is written relative to it; everything else keeps its
full absolute path** (`lsp.rel`). `Req.root` is the directory of the file the
query was asked about — the window that generated the buffer — and a `gr` over
one file was otherwise the same forty-character prefix repeated down the whole
pane, with the part you came to read pushed off the right edge. The short form
resolves because `Req.root` is also the directory the results buffer is *named*
in (`output_pane.open`), and a look resolves a relative word against the
directory of the pane it was clicked in — which is that buffer.

Under, never "shorter": a hit outside the tree is **not** walked up to with
`../`. An absolute path resolves from anywhere and says where it is; a `../..`
chain says neither, and stops being true the moment the row is read anywhere but
beside its own buffer. `test/snapshots/lsprelpath.snap` pins both directions end
to end — the enum one level up (absolute row) and one level down (`inner/tint.zig`,
a stripped path that still has a separator in it), each with the `n` step that
selects it and the Enter that opens it, plus a right click.

This is the rule `look.grep` follows for its own rows, and since the client
landed it is spelled ONCE: look.zig's `grep` calls `lsp.rel` for its `shown`
paths rather than keeping the inline twin this paragraph used to complain
about.

`completion` is the kind this shape changes the most. Every other editor answers
a dot with a popup of NAMES to insert; a seam that returns locations cannot
insert anything, so this one answers with the candidates' **declarations** —
one row each, in the same `+Search` buffer, steppable with `n`:

```
path:LINE:COL-ENDCOL   name  the candidate's declaration line
a.zig:2:5-13           verdigris  verdigris,
```

The name comes first because that is the thing you would type — the row answers
"what goes here" before "where does it come from", which is the order the
question was asked in; every other kind here answers a WHERE, and for this one
the location is the evidence rather than the answer. It is not padded into a
column: the location in front of it is already ragged, so there is nothing to
align to. That is a different and arguably better answer to "what goes here":
you get the word AND you can read the definition rather than a list of words. It
is the one location kind that does NOT jump on a single row, because with one
candidate you still want to see the list rather than be teleported into it.

A results buffer is REFILLED rather than reopened when the same kind is asked
again — the rule `runSearch` always had, and which the language path was
missing. It survived being missing while every query was a deliberate press
(`gr` twice left two identical lists and you closed one); Tab after a dot is an
ordinary typing keystroke, and measured, twenty of them stacked **fifteen**
byte-identical `+Search` panes, crushed the file to one visible line, and then
ran `freeSlot` out so the key was silently eaten for the rest of the session.
Unlike a search the ARGUMENT is not part of the identity: a language query is
asked about a different symbol every time with the same (usually empty) arg, so
the kind is the unit.

Making it work needed one trick. A completion is asked for exactly when the
line is half-typed, and a half-typed line does not parse: `switch (e) { . }`
loses the whole switch to the parser's error recovery, taking with it every
ancestor an expected-type resolution needs. ZLS answers this with a private
token scanner welded to its `*Server`. `lsp_zls.completionSource` instead makes
the tree PARSE — it splices a placeholder in after the dot, in the six
spellings a half-typed line can need — three shapes, each with and without a
closer still hanging: a switch prong (`_p => {},` / `_p => {}, }`), an
unterminated statement (`_p;` / `_p)`), and a bare identifier (`_p` / `_p }`)
— and keeps the one that both makes
the dot reachable in the tree and leaves the fewest parse errors. Everything
after that is ZLS's ordinary public resolution over an ordinary tree.

**A Tab the backend cannot answer still indents.** The keystroke has already
diverted by the time "no rows" comes back, so `lspResponse` performs the indent
the Tab prong skipped — on the condition that the cursor has not moved since,
so nobody who kept typing gets four spaces landing behind their hands. Without
that, a dot in a comment, in a string, or on a line nothing can be made of ate
the keystroke outright. With several cursors Tab never diverts at all: a
language query is a per-keystroke action inside a per-selection replay, so
asking would stop the replay dead and collapse the multicursor.

### What it costs, and what it cannot do

Per press. **Both timings date from 2026-08-09**, change `lmlltvrx`, and have
not been re-measured; the line count beside the first was refreshed once
afterwards, on 2026-08-12 in change `vwtlskzr`, and `src/pardes.zig` is 16 466
lines today. The ReleaseFast column is `zig build lspbench`, which is pinned to
ReleaseFast in `build.zig` and always has been — so the Debug column came from
running the installed editor by hand and the repo records no harness for it. A
completion parses the buffer once per placeholder spelling it tries, so the
first row scales with the file: re-run rather than trusting either number.

| | ReleaseFast | Debug (what `zig build` installs) |
|---|---|---|
| a switch arm in `src/pardes.zig` (14.6k lines) | 8.8 ms | 87 ms |
| `std.` — 91 candidates, each alias-resolved into the stdlib | 26 ms | 204 ms |

It is a worker thread, so the editor does not block. On the TTY shell the
second press of Tab then joins the first query on the UI thread —
`old.cancel(s.io)` on the one in-flight future, and a backend that ignores
cancellation means waiting out a query the user already abandoned
(`src/tty/tty.zig`, the `lsp` vtable entry, whose own comment says so). The
GUI shell does not join: it spawns another thread per request and lets the
core's monotonic id make the older answer stale, so it pays memory instead of
latency. Pre-existing and shared by every LSP kind — not this feature's to
fix, but it is what a fast double-Tab feels like on a terminal.

Known limitations, in the order you will meet them:

- **`@This()` anywhere in a container makes the whole container unresolvable**,
  so `var list: std.ArrayList(u8) = .` — the most common decl literal in this
  codebase — answers nothing. This is not the completion filter: `hover` and a
  plain field access on the same struct return nothing either. It is the case a
  user hits first, and it is upstream of everything here.
- **Only the break AT THE CURSOR is repaired.** Zig's error recovery runs
  forward, so an unrepaired break earlier in the file swallows the declaration
  the cursor is in and the answer is empty. While typing you normally have one
  broken spot, which is the case this works for.
- **A dependency module** (`@import("vaxis")`) cannot be typed at all, for the
  same reason `gd` on `vaxis.init` finds nothing.
- **`error.`** is not handled — the position context is `.error_access`, which
  no branch claims.


## The protocol client: every other language

`src/lsp/lsp_client.zig` is the second backend behind the same seam: a real
LSP client — JSON-RPC 2.0, `Content-Length` frames — speaking to child
processes. Nothing in it knows any single language; `specs` is a table of
(binary, languageId, extensions, root markers), and rust-analyzer, clangd,
gopls, typescript-language-server and pyright are rows in it. The seam asks
each backend `speaks(path) and supports(kind)` in order, so `.zig` stays with
the in-process analyser (cold is warm, no process) and everything else routes
here. `SPC l i` prints both sections; `backend_name` is `zls-inproc+lsp-client`.

**One server per spec, one reader thread per server, and the reader is not
optional.** A real server TALKS: rust-analyzer streams `$/progress` for the
whole minutes-long index of a big workspace, publishes diagnostics nobody
asked for, and asks its own `workspace/configuration` questions mid-flight.
The reader owns the read side of the socketpair, routes responses to the one
waiting query (a mailbox under the connection's mutex), answers
server-to-client requests so the server never blocks on us, feeds the
diagnostics store, and narrates state changes through the STATUS SINK — a
callback both native shells register at startup and post to their event
queue, so "rust-analyzer: cargo check 88% 955/1083" lands on the same
transient message row a save narrates into (`message.stamp`, verb `lsp`, on
the ACTIVE pane — server state is session news, not a fact about the pane
that asked). Chatty progress is throttled to one post per 150ms per server
and deduplicated; state CHANGES (starting, ready, exited, errors) always
land, and repeating the row already shown never does — which is also what
makes the settled state deterministic for the snapshot goldens.

Nothing may wedge the editor, and nothing healthy may be killed for being
busy:

- every write and every mailbox wait is deadline-bounded (8s handshake, 4s
  request); a query the server does not answer in time returns no rows and
  sends `$/cancelRequest`;
- three CONSECUTIVE timeouts mean wedged and force a restart — but only
  while the server is idle. One with active `$/progress` (rust-analyzer
  mid-`cargo check` over a thousand crates) is demonstrably alive, already
  narrating its own excuse on the message row, and killing it would throw
  the index away right before it pays off. This rule exists because the
  first run against a thousand-crate workspace did exactly that;
- a failed spawn or handshake is NOT a session disable: it backs off
  exponentially (10s doubling to 2min, reset by the next success), because
  the failure that taught this was a rustup shim deciding to download the
  project's whole pinned toolchain before launching the real server. Only a
  missing binary disables a spec, once, with a message saying which env var
  overrides it;
- a server that dies is reaped by whoever saw it die (the reader on EOF,
  `shutdownIf` on a transport error), the fd is closed by the READER ALONE —
  `shutdown(2)` first, so a polled fd number is never recycled under a
  thread still watching it — and the next query respawns, generation-checked
  so a stale worker can neither adopt nor kill its successor's server.

`PARDES_LSP_RS` / `_C` / `_GO` / `_TS` / `_PY` override each spec's binary
(a path or a PATH name); the empty string disables the spec. The snapshot
harness pins `_RS` to `test/lspmock.zig`'s deterministic mock and empties
the rest, so `test/snapshots/lsp-client.snap` (gd across files, gr spans,
n/Enter) and `lsp-client-edit.snap` (format apply, rename apply, one-step
undo for each) drive the REAL client — spawn, handshake, reader, narration —
against answers a golden can quote. `zig build lspprobe -- gd <file> <l>:<c>`
is the same seam from the command line, for pointing at any real workspace;
comma-separated kinds share one server so a big index is paid for once.

The root is helix's `find_root` rule: walking up from the file, the TOP-MOST
directory holding one of the spec's markers wins (a cargo workspace's root
`Cargo.toml` beats the member crate's), the closest `.git` is the fallback,
the asking directory the last resort. A second project in the same session
becomes a workspace FOLDER when the server advertises support. Position
encoding is negotiated to utf-8 and the server's ANSWER is believed; the
utf-16 conversion is implemented in both directions for servers that refuse.
Diagnostics PULL (`textDocument/diagnostic`, LSP 3.17) is preferred when the
server advertises it — rust-analyzer does — and the push store fed by the
reader answers otherwise, `]d` stepping either for free.

### Mutating answers really mutate now

The seam grew a second record form beside rename's `@edit`: `@put START END
TEXT` carries a per-range replacement, percent-encoded onto the one line a
record is allowed to be (`lsp.put`). The core decodes, validates (ordered,
non-overlapping, in bounds, revision unchanged) and applies ALL records as
one undo transaction, then says so on the message row ("formatted 1
range(s)", "renamed 2 range(s)"). So:

- `=` FORMATS, like helix — through the client it applies the server's
  TextEdits; through ZLS it applies one span covering everything `zig fmt`
  would change. The two non-edit answers stay prose in `+Lsp`: a file that
  does not parse, and (client-side) a server with no formatter.
- `SPC l r` through the client applies a WorkspaceEdit that stays inside the
  asked-about file. One that spans OTHER files (a real workspace rename)
  arrives as location rows instead and opens as a PREVIEW list in the same
  buffer `gr` fills — applying a fraction of a workspace rename silently
  would be worse than either. The ZLS backend still resolves and renames
  current-file references via `@edit`, exactly as before.

### Four kinds helix does not have

The hierarchy kinds are two-step in the protocol (prepare at the cursor,
then follow the item), are gated on the server capability so an old server
costs zero round trips, and their answers are LOCATIONS — the one thing this
seam renders for free. helix has no binding for any of the four (checked
against helix-term/src/keymap/default.rs).

| keys | kind | what the rows are |
|---|---|---|
| `SPC l c` | incoming_calls | one row per CALL SITE, under the caller's name |
| `SPC l C` | outgoing_calls | the callees' declarations |
| `SPC l t` | supertypes | the types this one extends/implements |
| `SPC l T` | subtypes | the types that extend/implement this one |

All four behave like `gr`: a list to walk with `n`/`N`, and a lone answer is
a jump.

## Which ZLS, and which stdlib

Both are decided at build time, and `SPC l i` prints both.

`build.zig.zon` pins ZLS to a COMMIT rather than a tag —
`git+https://github.com/zigtools/zls#3e0d082084be43e36865136a138c1fe2023b33ca`,
on the 0.16.x branch — because master requires Zig 0.17-dev and no tagged
release both builds on 0.16 and exports the internals this backend calls.
`build.zig` spells the same commit a second time, as the top-level
`const zls_version = "0.16.1-dev+3e0d0820"`, and hands it to the ZLS package's
own `-Dversion-string` and to `pardes_config.zls_version`. The duplication is
unavoidable rather than sloppy — the semver half (`0.16.1-dev`) exists nowhere
in the manifest — and it is load-bearing, because ZLS's build otherwise
derives that string from `git describe`, which has nothing to read in a
fetched package with no `.git`. The two are made to AGREE BY CONSTRUCTION: a
`comptime` block right below the constant takes the short hash after the `+`,
takes the pinned commit after the `#` in `zon.dependencies.zls.url`, and
`@compileError`s unless the first is a prefix of the second. A `.zon` bump
that forgets `build.zig` is therefore a build error, not a `SPC l i` naming a
build nobody linked.

`std` is the harder half. ZLS resolves `@import("std")` through `zig_lib_dir`
and through nothing else, and this backend sets `zig_exe_path = null` on
purpose — asking the `zig` binary is the subprocess the whole design exists to
avoid. So `build.zig` bakes `b.graph.zig_lib_directory.path` into
`pardes_config.zig_lib_dir`: the exact stdlib pardes itself was compiled
against, which is what makes `gd` on `std.mem.count` land in the real
`mem.zig`. `zigLibPath()` (`src/lsp/lsp_zls.zig`) reads `ZIG_LIB_DIR` from the
environment FIRST and falls back to the baked path, so a user who moved the
toolchain can point the backend at it without rebuilding. With no lib dir at
all every `std` symbol is a silent miss — which is why `SPC l i` reports
whether the directory OPENS rather than only which one was compiled in.

That same `zig_exe_path = null` is the dependency-module limitation above.
ZLS resolves a relative `.zig` path from the filesystem and `std` from the lib
dir, but any other import name — every dependency in `build.zig.zon` — it can
only answer by running `zig build --build-runner` to discover the module
graph. With no zig binary that branch returns nothing, so `@import("vaxis")`
is a silent miss by construction rather than by omission.

## 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 l k` | hover | opens `+Hover` |
| `SPC l r` | rename | tag input; applies same-file edits in one undo step, PREVIEWS a multi-file WorkspaceEdit as rows |
| `SPC l a` | code action | |
| `SPC l h` | select references | |
| `SPC l s` / `SPC l S` | document / workspace symbols | `S` takes a query |
| `SPC l d` / `SPC l D` | document / workspace diagnostics | |
| `]d` / `[d` | next / prev diagnostic | steps the list, asks for one if absent |
| `]D` / `[D` | last / first diagnostic | |
| `=` | format | applies the formatter's edits in one undo step |
| `Ctrl`+left-click | definition | the mouse spelling of `gd` |
| `Tab` in INSERT mode, right after a `.` | completion | what could go here, and where each of those is defined |
| `SPC l c` / `SPC l C` | incoming / outgoing calls | beyond helix — see the client section |
| `SPC l t` / `SPC l T` | supertypes / subtypes | beyond helix — see the client section |

Tab is the one key here that is not helix's and not a goto. helix's `Tab`
completes; pardes's shows you the CANDIDATES' DECLARATIONS in a `+Search`
buffer and inserts nothing, because that is what a seam returning locations can
honestly do — see below. It only diverts where an answer is possible: on a
terminal, in an output buffer, or in a file the backend does not speak
(`lsp.speaks`, which the core asks and the backend answers), Tab indents
exactly as it always did. A Tab that silently does nothing would be worse than
not having the feature. Nothing about the mode changes either — the pane is
still in insert, so typing goes on and walking the answer with `n` means
pressing `Esc` first, the same as for every other results buffer.

Ctrl-click rides the ordinary left-click drag rather than firing on the press:
a click does not place the modal cursor until RELEASE, so a query asked at
press time would answer about wherever the cursor previously sat. A ctrl-DRAG
still selects, and still asks about where it started.

**The gotos are helix's exactly; the leader commands are helix's letters under
an `l` prefix.** `g` and `[`/`]` had no conflicts — `gd/gD/gy/gi/gr` and
`]d/[d` were all free, so they stay where helix puts them. The bare `<space>`
letters were NOT free, and an earlier pass took them anyway, displacing `SPC d`
(Del), `SPC k` (Kill), `SPC s?` (Dump/Restore) and `SPC h t` (Tutor). That is
the wrong trade: those are pardes's most-pressed keys and predate the language
work, whereas an LSP command is something you reach for deliberately and can
afford one keystroke more. So every one of them keeps helix's own letter and
gains the prefix — `<space>k` becomes `SPC l k`, `<space>d` becomes `SPC l d`
— and nothing pardes had moved at all.

Two more live in the same group because they belong to it, not to helix (the
four hierarchy kinds above are also pardes's own — helix has no spelling for
them):

| keys | builtin | what it shows |
|---|---|---|
| `SPC l i` | `Lspinfo` | BOTH backends: which ZLS and which stdlib (and whether it opens); every protocol server's state, root, encoding and capabilities; and each side's last 24 queries with timings, row counts and **the errors `query` swallowed** |
| `SPC l w` | `Lspwhy` | why the definition query at the cursor answers what it does — narrated by whichever backend the file routes to |

These exist because of the seam's own contract: a backend never fails loudly,
which is right for an editor — a thrown analyser must not take the process with
it — but it makes a broken backend and a correct one that found nothing look
identical. `Lspwhy` narrates the REAL resolution path (the position context is
the analyser's own answer, threaded out through a trace) rather than
re-deriving it beside the code, because a debug view that reimplements the
logic is one that can disagree with it. `Lspinfo` answers from ANY pane,
including one with no file, since it is about the backend rather than a
document — which matters precisely when the pane you are in is the problem.

`K` is **not** hover — in helix it is `keep_selections`. It was checked; do not
"fix" it.

## Writing a backend

`src/lsp/lsp.zig` is the seam, and since the protocol client landed it holds a
LIST of backends, asked in order: the first one that `speaks` the file's
language and claims the kind in `supports` answers. An implementation supplies
three things and touches nothing else (`backend_name` is the SEAM's, not
yours — one literal in `lsp.zig` naming the compiled-in combination; a backend
with unsolicited news to deliver may additionally accept the status sink, as
`setStatusSink` shows):

```zig
pub fn query(gpa, arena, req: Req, out: *std.Io.Writer) void
pub fn speaks(path: []const u8) bool
pub const supports: std.EnumSet(Kind)
pub const backend_name = "..."
```

`supports` says what a backend can do; `speaks` says what it can do it TO, and
exists for the one key that must not be eaten when the answer is no — see the
Tab note above.

`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`).
`out` is a plain `std.Io.Writer`: the shell owns the buffer behind it (an
`Io.Writer.Allocating`), so a backend never allocates the result, never frees
it, and cannot get the allocator wrong. `arena` is freed wholesale on return;
`gpa` is for a backend's own scratch. Use `lsp.row()` to emit a location,
`lsp.spanRow()` for one that carries the RANGE it matched (the
`path:LINE:COL-ENDCOL` form above), `lsp.rel()` to spell a path against
`req.root`, `lsp.lineCol()` to convert an offset, and `lsp.edit()` for each
half-open range of a rename response. Location
rows are byte-identical across backends; rename ranges are consumed by the core
and never rendered. `rel` allocates nothing — it returns a slice of what you
hand it.

## How the implementations are judged

`zig build lspbench` — same harness, same corpus, same 22 probes, every backend.
The corpus is pardes's own `src/`, plus `test/lspfixture/`: five of the probes
point at fixtures rather than at real source. Three of them do because on
clean, already formatted code the correct answer to `diagnostics`,
`workspace_diagnostics` and `format` is nothing, and that is indistinguishable
from a backend that has neither — `broken.zig` carries an unused local and a
misformatted fn, so all three have real work. The other two are the half-typed
dot, whose two shapes are `dotcomplete.zig` (a switch prong that parses
everywhere but at the dot) and `dothalf.zig` (a line also missing its
terminator). The 22 probes cover 15 of the 17 `lsp.Kind`s
— `definition` three times, `document_symbols` twice, `completion` five times,
and the two introspection kinds (`status`, `explain`) not at all.

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

The user-visible rename contract is also pinned through the actual TTY,
leader prompt, worker and ZLS backend by `test/snapshots/lsp-rename.snap`: both
resolved occurrences change, a shadowed local does not, and undo/redo treats
the response as one transaction.

Run `zig build lspbench -- --json` for machine-readable output.