summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-29 10:20:50 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitcc173a821bd6fd84fa6e7c2f272b7e049c7a1aaa (patch)
tree23dadf74493c0172ea7fced1dff44d2a4178c752 /docs
parent4e642c1d6688baf3b98f269818b66cc7cc194c5f (diff)
downloadpardes-cc173a821bd6fd84fa6e7c2f272b7e049c7a1aaa.tar.gz
pardes-cc173a821bd6fd84fa6e7c2f272b7e049c7a1aaa.zip
lsp: writer seam, ZLS introspection builtins, ctrl-click goto, SPC l group
THE SEAM TAKES A WRITER. `lsp.query`'s `out` is a `*std.Io.Writer`, not a `*std.ArrayList(u8)`. 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 — the invalid-free class of bug has nowhere left to live. It also deleted a parameter from five functions: they only ever took a `gpa` to allocate rows, and 0.16's unused-parameter error found every one. `lsp.row()` lost its allocator argument too. Since a Writer cannot rewind or be counted, `query` renders into a scratch Allocating first: the log wants an exact row count, and `explain` throws the rows away and prints narration in their place. INTROSPECTION. `SPC l i` (Lspinfo) and `SPC l w` (Lspwhy), in the `l` group that now holds every language command (see below). They exist because of the seam's own contract: a backend never fails loudly, which is right for an editor, but it makes a broken backend and a correct one that found nothing look identical from the outside. Every query now leaves a record — kind, file, offset, duration, row count, and THE ERROR `run` returned, which `catch {}` swallowed and which was visible nowhere. Lspinfo prints those, plus which ZLS is compiled in, which zig lib dir and whether it actually opens (the usual cause of "gd does nothing in std"), and what the backend answers versus refuses. It answers from ANY pane, including one with no file, because it is about the backend — which matters precisely when the pane you are sitting in is the problem; both shells now send status for a file-less pane. Lspwhy narrates the REAL resolution path. The trace is threaded through `goto` itself, so what it prints is the position context the analyser returned and the branch that actually stopped. A debug view that re-derives the logic beside it is one that can disagree with it. CTRL-CLICK IS gd. Mouse gained a `ctrl` field, set by both shells (SDL asked directly via GetModState rather than read off key-event bookkeeping, which a click with no prior keypress would miss). The flag rides the 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. The snapshot DSL gained a `ctrl-` button prefix (SGR bit 4, what a terminal sends and what vaxis decodes). test/snapshots/lspdebug.snap covers all three, including a PLAIN click in the same spot that must NOT jump — without it the test would pass on a bug that made every click a goto. Durations cannot live in a golden, so PARDES_LSP_NOTIME (set by the harness, like PARDES_DUMP) omits them. 58 snapshot scripts, hxdiff 360, hxparity 440, unit 46, gui build: all green. lspbench: 17/17, 0 false claims. THE WHOLE LANGUAGE GROUP LIVES UNDER SPC l. pardes keeps its own leader letters back. `SPC d` is Del again, `SPC k` is Kill, `SPC s d`/`SPC s r` are Dump/Restore and `SPC h t` is Tutor — exactly where they were before the language work touched them. The previous pass put the LSP commands on helix's bare `<space>` letters and moved pardes's builtins out of the way (Kill k->q, Del d->wc, Dump/Restore s?->f?, Tutor ht->T). That was the wrong trade. Those five are the most-pressed keys in the editor and predate the language work; an LSP command is something you reach for deliberately and can afford one keystroke more. So every LSP command keeps HELIX'S OWN LETTER and gains the `l` prefix: `<space>k` -> `SPC l k` (hover), `<space>d` -> `SPC l d` (diagnostics), r/a/h/s/S/D likewise. Nothing to re-learn but the prefix, and `Lspinfo`/ `Lspwhy` were already there. THE GOTOS ARE UNTOUCHED. `gd` `gD` `gy` `gi` `gr`, `]d`/`[d`, `]D`/`[D`, `=` and ctrl-click all stay exactly as helix has them — they never collided with anything, so there was never a reason to move them, and they are the ones you actually press mid-edit. leader.snap is restored to the pre-LSP script (its `key q` unmapped-key step works again now that Kill is back on `k`) plus one new step for `SPC l ?`. Its `SPC ?` root listing had to stop waiting on Restore: the full list grew to 33 rows and row 21 falls off the pane, so it watches an early row instead. DEPENDENCY IMPORTS NOW RESOLVE. `gd` on `@import("vaxis")` opens vaxis's root file; before, it silently did nothing while `std` worked perfectly. The asymmetry was not a wiring mistake. ZLS's uriFromImportStr answers exactly three ways: a relative `.zig`/`.zon` path from disk, `std` from `zig_lib_dir` (one directory, which we supply), and EVERY OTHER NAME only by running `zig build --build-runner` to discover the module graph. That last branch needs `zig_exe_path`, which this backend sets to null on purpose — so every dependency import returned `.none`. Confirmed twice over: in ZLS's source, and by `SPC l w` on the import string, which printed the STOP line naming exactly that branch. (The introspection builtin diagnosing its own backend on its first real outing is a decent argument for having built it.) We never needed a compiler for this: build.zig IS the module graph. It folds `root_mod.import_table` into a name -> root-source-file table at configure time and passes it as a build option; the backend consults it precisely where ZLS gave up. Correct by construction — a dependency added or renamed in build.zig cannot forget to update it — and it costs no subprocess, no build step and no runtime work. `SPC l i` now lists the table, since "is this name even importable" is the first question when a jump does nothing. Two limits, both stated in the code: a module whose root is a GENERATED file is skipped (it has no path until make() runs), and a file inside a dependency importing that dependency's OWN internal module name is still a miss — that would mean running its build.zig. TRAP: the table is folded out of root_mod.import_table, so `addOptions` had to move BELOW every `addImport` call. Attached where it was, the table is empty. TOPBAR GAINS `Help`, WHICH IS WHY `SPC ?` LOOKED BROKEN. A bare `pardes` boots straight into tty mode (main.zig: `args.len == 1`), where every printable key belongs to the shell — so SPC never reaches the leader, and `SPC ?`, the one thing that would tell you the leader exists, is exactly the thing you cannot press. Ctrl-b first and it all works; nothing was broken. But "the help is unreachable until you already know the escape hatch" is a bad answer, and there was no mouse route either: Help was the one builtin missing from the bar. Row 0 is not a pane, so a middle-click there is dispatched before any pane's mode is consulted — the word works in tty mode, which is the only reason it earns the width. APPENDED, not inserted, so every existing topbar word keeps its column and no golden's click coordinates move. test/snapshots/ttyhelp.snap pins it from a bare boot: click Help, get the list, shell still TTY at its prompt, then Ctrl-b + SPC ? for the keyboard route. All 58 goldens carry row 0, so all 58 moved. Verified mechanically that the only changes are the row-0 text and the row-0 style run (0-47 -> 0-52), plus: dump/restore record the topbar inside their .zon, and tagnav's `$`+Enter now executes `Help` rather than `Grep` because the bar's last word changed — still exactly what that step's comment claims it tests. THE DEPENDENCY FIX HAS A CEILING, NOW STATED. The module map is consulted from OUR goto handler, not from inside ZLS, so the analyser still cannot type the `vaxis` const: `gd` on `@import("vaxis")` opens the file, `gd` on `vaxis.init` finds nothing. That is now spelled out at the top of lsp_zls.zig and on moduleRoot rather than left implied, and `SPC l w` detects the case by name — if the left side of a failed field access is a known dependency it says so, instead of the generic "could not resolve". Lifting it means giving ZLS a real BuildConfig, either by letting it run the build runner (a subprocess, and with no cross-query cache that is once per keypress) or by synthesizing one into BuildFile.impl. Both are real work and neither is smuggled in. Also fixed while there: the field-access miss was only explained when ZLS returned null, but it returns an EMPTY SLICE when it typed the left side and found no such member. Both are "gd did nothing" from the outside; both are explained now.
Diffstat (limited to 'docs')
-rw-r--r--docs/lsp-evaluation.md23
-rw-r--r--docs/lsp.md65
2 files changed, 63 insertions, 25 deletions
diff --git a/docs/lsp-evaluation.md b/docs/lsp-evaluation.md
index ae822213..5f660640 100644
--- a/docs/lsp-evaluation.md
+++ b/docs/lsp-evaluation.md
@@ -2,16 +2,16 @@
Same core, same seam (`src/lsp.zig`), same 17-probe harness (`zig build lspbench`)
over the same corpus. Three jj workspaces, three independent implementations,
-one function each.
+one function each. C landed; see **Decision** below.
| | **A · stdlib** | **B · client** | **C · in-process** |
|---|---|---|---|
-| workspace | `pardes-ws-ast` | `pardes-ws-proto` | `pardes-ws-inproc` |
+| change | `nvqpknzm` | `stuurqqt` | **landed** |
| what it is | `std.zig.Ast` + `AstGen`, hand-rolled scope walk | `zls` as a child process, JSON-RPC over a socketpair | ZLS linked as a Zig module, analyser called directly |
| **probes correct** | 12 / 17 | **17 / 17** | **17 / 17** |
| **false claims** | 0 | 0 | 0 |
| **implementation LOC** | 622 (+307 test) | 721 | **540** (+29 build) |
-| new dependency | **none** | a `zls` binary at runtime | ZLS source (path dep) |
+| new dependency | **none** | a `zls` binary at runtime | ZLS source (fetched into `zig-pkg/`) |
| binary size vs base | +6 MiB | +14 MiB | +35 MiB |
| memory | 3.7 MiB | 1.1 MiB **+ 37 MiB in the child** | 5.5 MiB |
| processes | **1** | 2 | **1** |
@@ -82,7 +82,22 @@ All three keep the 56 existing snapshot scripts green, add a 57th driving the
real binary through the helix keymap, and pass `hxdiff` (360) and `hxparity`
(440) with no new waivers.
-## Recommendation
+## Decision
+
+**C landed.** It is what `src/lsp_zls.zig` is, and ZLS is now a fetched
+dependency (`build.zig.zon`, pinned to commit `3e0d0820` on the 0.16.x branch)
+so it sits in `zig-pkg/` like everything else and the build is reproducible
+from the .zon alone — no path dependency on a local checkout.
+
+A and B were not deleted, only un-worktree'd. They remain whole commits:
+
+- **A · stdlib** — change `nvqpknzm`
+- **B · client** — change `stuurqqt`
+
+Both are one `jj new <change>` away if the multi-language argument below wins
+later, or if the ZLS coupling ever needs backing out.
+
+## Recommendation (as written before the decision)
**C (in-process), with B as the answer to a question pardes has not asked yet.**
diff --git a/docs/lsp.md b/docs/lsp.md
index 987e901c..2a4c8784 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -77,29 +77,49 @@ Verified against `helix-term/src/keymap/default.rs`, not from memory.
| `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 | |
+| `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` |
-`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:
+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.
-| 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 |
+**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.
-A three-exception muscle-memory map is not a map. That is the whole argument.
+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.
@@ -110,16 +130,19 @@ A three-exception muscle-memory map is not a map. That is the whole argument.
nothing else:
```zig
-pub fn query(gpa, arena, req: Req, out: *std.ArrayList(u8)) void
+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`).
-`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.
+`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