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
|
# 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 -> 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
/abs/path/to/file.zig:LINE:COL-ENDCOL text
```
1-based line and column, absolute path. 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 `gr` steps references with each one
highlighted. 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 (`lookAt`)
- **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 l k` | hover | opens `+Hover` |
| `SPC l r` | rename | tag input, like Find/Grep |
| `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 | |
| `Ctrl`+left-click | definition | the mouse spelling of `gd` |
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:
| keys | builtin | what it shows |
|---|---|---|
| `SPC l i` | `Lspinfo` | which ZLS, which stdlib (and whether it opens), what the backend answers and refuses, plus the 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 |
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. An implementation supplies three things and touches
nothing else:
```zig
pub fn query(gpa, arena, req: Req, out: *std.Io.Writer) 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`).
`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 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.
|