diff options
Diffstat (limited to 'docs/lsp.md')
| -rw-r--r-- | docs/lsp.md | 146 |
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. |
