summaryrefslogtreecommitdiff
path: root/docs/lsp.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/lsp.md')
-rw-r--r--docs/lsp.md146
1 files changed, 123 insertions, 23 deletions
diff --git a/docs/lsp.md b/docs/lsp.md
index 54ffd764..1adb397b 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -29,18 +29,62 @@ core shell worker
```
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 compiles in no backend at all (`zls_backend` is off for wasm), which
-makes `lsp.supports` empty, which makes `lspRequest` return before it emits —
-so on the web the effect is never even raised. `web.zig` leaves the host's
-`lsp` method null and exports nothing for a response; a host that links a
-backend would add both.
+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.
-Three rules make it safe:
+**In a DETACHED session every query answers EMPTY.**
+`src/detached/server.zig`'s vtable implements sixteen of `host.zig`'s
+twenty-one methods, and `pull_lsp` is one of the five 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 five 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; and `push_fs_reply` is the one
+`perform` arm with no fallback at all, but it is only ever emitted in answer to
+an `Event.fs_req`, which is not on the wire (`wire.zig`: it has no `ClientTag`,
+because neither half of that pair may cross an attachment) and which the
+daemon raises none of, mounting no `/dev/fuse` by its own vtable comment.
+
+Four 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
+ 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
@@ -107,9 +151,11 @@ to end — the enum one level up (absolute row) and one level down (`inner/tint.
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` already follows for its own rows (`look.zig`, the
-`shown` computation), written a second time; the two are now the same function
-and want to become one.
+This is the rule `look.grep` already follows for its own rows, and it is
+spelled TWICE: `lsp.rel` here, and an inline `if` over the asking pane's
+directory in `look.zig`'s `grep` (the `shown` computation) there. Same rule,
+two implementations — they want to become one function, and `lsp.zig`'s own
+comment on `rel` says so.
`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
@@ -166,17 +212,29 @@ asking would stop the replay dead and collapse the multicursor.
### What it costs, and what it cannot do
-Per press, measured by `zig build lspbench` on this repo:
+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; but the second press of
-Tab joins the first query on the UI thread (`old.cancel(io)` in the shell) and
-that wait is real. Pre-existing and shared by every LSP kind — not this
-feature's to fix, but it is what a fast double-Tab feels like.
+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:
@@ -194,6 +252,46 @@ Known limitations, in the order you will meet them:
- **`error.`** is not handled — the position context is `.error_access`, which
no branch claims.
+## 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.
@@ -299,12 +397,14 @@ hand it.
`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, because on clean, already
-formatted code the correct answer to `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; `dotcomplete.zig` and `dothalf.zig`
-carry the two shapes of half-typed dot. The 22 probes cover 15 of the 17
-`lsp.Kind`s
+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.