summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-28 23:30:29 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitf43c1e11b44e2464f0bb0b635e0abcaf3c717e23 (patch)
treeddc5e5528ebb59f72d30bef49522f814578e4fce
parent65b207c3392c75eac3f2b18a266a6482d8345df1 (diff)
downloadpardes-f43c1e11b44e2464f0bb0b635e0abcaf3c717e23.tar.gz
pardes-f43c1e11b44e2464f0bb0b635e0abcaf3c717e23.zip
LSP seam: async execution model, helix keymap, evaluation harness
The base every language backend plugs into. Three parts: ASYNC. The core had no request/response shape - every effect was fire-and-forget or instantaneous. A language query is the first thing that answers later, so: Effect .lsp -> shell worker -> Event .lsp_resp. tty.zig uses io.concurrent + the vaxis queue, gui.zig a detached thread + the mutex queue it already had for ptys; web no-ops it. The worker never touches the core (path/source/arg are snapshotted into an LspJob), one query in flight identified by a monotonic id so a second press makes the first answer stale, and no rows is a legal answer. KEYMAP. Helix's, verified against its default.rs rather than recalled. gd/gD/gy/gi/gr and ]d/[d had no conflicts. The SPC letters did, so pardes's own builtins moved instead of helix's: Kill k->q, Del d->wc (closing a pane is a window op, and c is helix's own close), Dump/Restore s?->f?, Tutor ht->T. A three-exception muscle-memory map is not a map. RESULTS ARE +SEARCH ROWS. path:LINE:COL text, absolute. That is what look.zig resolves and n/N step, so one row from a goto jumps and several open a buffer - helix's multi-result picker needed no picker code. Backends supply exactly one function (lsp.query) plus a supports set and a name; the base has none on purpose. zig build lspbench scores them on the same corpus: feature matrix (trusting results, not the supports flag - a claimed-but-empty kind is reported as a false claim), cold and warm latency, peak RSS. Two snapshot scripts moved. leader.snap encoded the old key paths. chordcut.snap's last two steps clicked column 5, which lands on a FILE pane, so 'key c-b' toggled nothing and the typed text was being read as normal-mode keys - the golden recorded no TTY pane and no cat -v output anywhere. Pointing them at an actual shell makes both steps assert what their comments claim, and the tty paste chord is now covered for the first time.
-rw-r--r--build.zig21
-rw-r--r--docs/lsp.md146
-rw-r--r--src/gui.zig122
-rw-r--r--src/lsp.zig141
-rw-r--r--src/pardes.zig198
-rw-r--r--src/tty.zig118
-rw-r--r--test/lspbench.zig264
-rw-r--r--test/snapshots/chordcut.golden56
-rw-r--r--test/snapshots/chordcut.snap20
-rw-r--r--test/snapshots/leader.golden116
-rw-r--r--test/snapshots/leader.snap38
11 files changed, 1110 insertions, 130 deletions
diff --git a/build.zig b/build.zig
index 6073bbcc..5f3ce06f 100644
--- a/build.zig
+++ b/build.zig
@@ -524,6 +524,27 @@ pub fn build(b: *std.Build) void {
}
b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] <cases.jsonl>...)").dependOn(&run_hxparity.step);
+ // the language-backend scoreboard. ReleaseFast on purpose: the point
+ // is to compare backends' real cost, and a Debug build measures the
+ // safety checks of whichever one allocates most. It links the same
+ // core module as hxdiff, so `lsp.query` here is the one the editor
+ // runs.
+ const lspbench = b.addExecutable(.{
+ .name = "pardes-lspbench",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = .ReleaseFast,
+ .root_source_file = b.path("test/lspbench.zig"),
+ .link_libc = true,
+ }),
+ });
+ lspbench.root_module.addImport("pardes", hx_core_mod);
+ b.installArtifact(lspbench);
+ const run_lspbench = b.addRunArtifact(lspbench);
+ if (b.args) |args| run_lspbench.addArgs(args);
+ run_lspbench.setCwd(b.path("."));
+ b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [repo-root])").dependOn(&run_lspbench.step);
+
// modal.zig is pure std — its inline unit tests run here
const unit = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
diff --git a/docs/lsp.md b/docs/lsp.md
new file mode 100644
index 00000000..987e901c
--- /dev/null
+++ b/docs/lsp.md
@@ -0,0 +1,146 @@
+# 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.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
+```
+
+1-based line and column, absolute path. 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 (`actOnSelection`)
+- **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 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 | |
+| `]d` / `[d` | next / prev diagnostic | steps the list, asks for one if absent |
+| `]D` / `[D` | last / first diagnostic | |
+| `=` | format | |
+
+`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:
+
+| 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 |
+
+A three-exception muscle-memory map is not a map. That is the whole argument.
+
+`K` is **not** hover — in helix it is `keep_selections`. It was checked; do not
+"fix" it.
+
+## Writing a backend
+
+`src/lsp.zig` is the seam. An implementation supplies three things and touches
+nothing else:
+
+```zig
+pub fn query(gpa, arena, req: Req, out: *std.ArrayList(u8)) 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.
+
+## 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.
diff --git a/src/gui.zig b/src/gui.zig
index e2c39ac1..e157ef28 100644
--- a/src/gui.zig
+++ b/src/gui.zig
@@ -493,7 +493,32 @@ fn normCell(norm: f32, win: f32, cell: f32) u16 {
const Pty = struct { fd: c_int, pid: libc.pid_t };
-const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, eof: struct { pane: u8, gen: u32, fd: c_int } };
+const Msg = union(enum) {
+ output: struct { pane: u8, gen: u32, bytes: []u8 },
+ eof: struct { pane: u8, gen: u32, fd: c_int },
+ /// a language query finished on its own thread (see lspThread)
+ lsp: struct { id: u32, rows: []u8 },
+};
+
+/// One language query, owned by the thread running it — the gui twin of
+/// tty.zig's LspJob, and copied for the same reason: the core edits on.
+const LspJob = struct {
+ id: u32,
+ kind: pardes.lsp.Kind,
+ offset: u32,
+ path: []u8,
+ source: [:0]u8,
+ arg: []u8,
+ root: []u8,
+
+ fn free(j: *LspJob, gpa: std.mem.Allocator) void {
+ gpa.free(j.path);
+ gpa.free(j.source);
+ gpa.free(j.arg);
+ gpa.free(j.root);
+ gpa.destroy(j);
+ }
+};
const Queue = struct {
gpa: std.mem.Allocator,
@@ -514,6 +539,7 @@ const Queue = struct {
q.mutex.unlock();
switch (m) {
.output => |o| q.gpa.free(o.bytes),
+ .lsp => |l| q.gpa.free(l.rows),
.eof => {},
}
return;
@@ -522,6 +548,7 @@ const Queue = struct {
q.mutex.unlock();
switch (m) {
.output => |o| q.gpa.free(o.bytes),
+ .lsp => |l| q.gpa.free(l.rows),
.eof => {},
}
return;
@@ -549,6 +576,7 @@ const Queue = struct {
q.closed = true;
for (q.items.items) |m| switch (m) {
.output => |o| q.gpa.free(o.bytes),
+ .lsp => |l| q.gpa.free(l.rows),
.eof => {},
};
q.items.deinit(q.gpa);
@@ -575,6 +603,68 @@ fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) v
th.detach();
}
+/// Answer a language query off the render loop and push the rows to the queue
+/// — the async execution model, spelled in the plumbing this shell already has
+/// (a detached thread and the mutex queue the pty readers use).
+fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void {
+ defer job.free(gpa);
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ pardes.lsp.query(gpa, arena.allocator(), .{
+ .kind = job.kind,
+ .path = job.path,
+ .source = job.source,
+ .offset = job.offset,
+ .arg = job.arg,
+ .root = job.root,
+ }, &out);
+ const rows = gpa.dupe(u8, out.items) catch return;
+ q.push(.{ .lsp = .{ .id = job.id, .rows = rows } });
+}
+
+/// Copy the query out of the core and hand it to a thread. A detached thread
+/// per query is fine at this rate: one keystroke, one query, and the queue
+/// already tolerates a late push after close.
+fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void {
+ const pane = core.panes[e.pane] orelse return;
+ const f = pane.file orelse return;
+ const job = gpa.create(LspJob) catch return;
+ job.* = .{
+ .id = e.id,
+ .kind = e.kind,
+ .offset = e.offset,
+ .path = gpa.dupe(u8, f.path) catch {
+ gpa.destroy(job);
+ return;
+ },
+ .source = gpa.dupeZ(u8, f.content) catch {
+ gpa.free(job.path);
+ gpa.destroy(job);
+ return;
+ },
+ .arg = gpa.dupe(u8, e.arg.slice()) catch {
+ gpa.free(job.path);
+ gpa.free(job.source);
+ gpa.destroy(job);
+ return;
+ },
+ .root = gpa.dupe(u8, std.fs.path.dirname(f.path) orelse "/") catch {
+ gpa.free(job.path);
+ gpa.free(job.source);
+ gpa.free(job.arg);
+ gpa.destroy(job);
+ return;
+ },
+ };
+ const th = std.Thread.spawn(.{}, lspThread, .{ gpa, job, q }) catch {
+ job.free(gpa);
+ return;
+ };
+ th.detach();
+}
+
// ---- the renderer state ----
const Gui = struct {
@@ -920,6 +1010,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
core.update(.{ .eof = .{ .pane = e.pane } });
}
},
+ .lsp => |l| {
+ core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
+ gpa.free(l.rows);
+ },
};
msgs.deinit(gpa);
// 3. steamdeck: poll gamepad axes into virtual cursor / wheel events
@@ -1332,6 +1426,11 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
}
n_events += 1;
},
+ .lsp => |l| {
+ core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
+ gpa.free(l.rows);
+ n_events += 1;
+ },
};
msgs.deinit(gpa);
drainEffects(core, &ptys, &gens, gpa, &queue, null, true);
@@ -1808,14 +1907,16 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
} });
}
},
- .key => |k| core.update(.{ .key = switch (k) {
- .n => .{ .cp = 'n', .text = "n" },
- .cap_n => .{ .cp = 'N', .text = "N" },
- .enter => .{ .cp = pardes.Key.enter },
- .tab => .{ .cp = pardes.Key.tab },
- // back paddle: Ctrl-<configured toggle key> flips tty mode
- .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true },
- } }),
+ .key => |k| core.update(.{
+ .key = switch (k) {
+ .n => .{ .cp = 'n', .text = "n" },
+ .cap_n => .{ .cp = 'N', .text = "N" },
+ .enter => .{ .cp = pardes.Key.enter },
+ .tab => .{ .cp = pardes.Key.tab },
+ // back paddle: Ctrl-<configured toggle key> flips tty mode
+ .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true },
+ },
+ }),
// a brief gentle ack for execute/look, not a buzz
.rumble => if (g.gamepad) |pad| {
_ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80);
@@ -2024,6 +2125,7 @@ fn drainEffects(
defer gpa.free(z);
_ = c.SDL_SetClipboardText(z.ptr);
},
+ .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e),
.quit => {},
};
}
@@ -2062,7 +2164,7 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void {
// look on a URL → a new tab
.open_link => |url| openLinkWeb(gpa, url.slice()),
// nothing to spawn/write/resize/save/dump into — all no-ops
- .spawn, .write, .resize_pty, .save_file, .write_dump, .quit => {},
+ .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .quit => {},
};
}
diff --git a/src/lsp.zig b/src/lsp.zig
new file mode 100644
index 00000000..62b5e1bb
--- /dev/null
+++ b/src/lsp.zig
@@ -0,0 +1,141 @@
+//! The language-intelligence seam.
+//!
+//! The core never speaks a protocol and never blocks. It emits an `lsp` Effect
+//! naming a Kind, a file and a byte offset; a shell runs `query` on a worker
+//! and posts the answer back as an `lsp_resp` Event. That is the whole async
+//! execution model — the same shape the pty readers already use, because a
+//! language query is just another thing that answers later.
+//!
+//! Every backend renders into ONE format: `+Search` rows. A location is
+//! `path:LINE:COL text`, which is what look.zig already resolves and what n/N
+//! already steps, so a multi-result answer IS helix's picker and a single
+//! result IS a jump — with no picker UI written for it. Free text (hover,
+//! a rename's diff) rides the same buffer as plain lines.
+//!
+//! `query` is the ONLY thing an implementation supplies. Swapping backends is
+//! swapping this one function, which is also how the three competing
+//! implementations are measured against each other: same core, same harness,
+//! same rows, different `query`.
+const std = @import("std");
+
+/// What the caller wants to know. The helix command each one backs is named
+/// alongside, because the keymap is helix's and these are its verbs.
+pub const Kind = enum {
+ /// gd
+ definition,
+ /// gD
+ declaration,
+ /// gy
+ type_definition,
+ /// gi
+ implementation,
+ /// gr
+ references,
+ /// SPC k
+ hover,
+ /// SPC s
+ document_symbols,
+ /// SPC S (arg = the query)
+ workspace_symbols,
+ /// SPC d, and the list that ]d / [d step
+ diagnostics,
+ /// SPC D
+ workspace_diagnostics,
+ /// SPC r (arg = the new name)
+ rename,
+ /// SPC a
+ code_action,
+ /// =
+ format,
+ /// SPC h
+ select_refs,
+
+ /// Whether an answer of exactly one row should JUMP rather than open a
+ /// results buffer. Helix: the five gotos jump on a single location and
+ /// show a picker on several; a symbol list is always a picker.
+ pub fn jumpsWhenSingle(k: Kind) bool {
+ return switch (k) {
+ .definition, .declaration, .type_definition, .implementation, .references => true,
+ else => false,
+ };
+ }
+
+ /// The buffer an answer opens. Kept distinct from `+Search` only where the
+ /// content is not a list of locations — n/N over prose is nonsense.
+ pub fn bufferName(k: Kind) []const u8 {
+ return switch (k) {
+ .hover => "+Hover",
+ .code_action, .format, .rename => "+Lsp",
+ else => "+Search",
+ };
+ }
+};
+
+/// One question. `source` is a snapshot of the buffer taken by the shell
+/// before the worker starts — the core keeps editing while this is in flight,
+/// so a backend must never reach back into core memory.
+pub const Req = struct {
+ kind: Kind,
+ /// absolute path of the file the offset is in
+ path: []const u8,
+ /// the buffer's bytes, NUL-terminated (std.zig.Ast and zls both want a
+ /// sentinel, and every backend has to parse this same text)
+ source: [:0]const u8,
+ /// cursor position, a byte offset into `source`
+ offset: u32,
+ /// kind-specific argument: the new name for a rename, the query for
+ /// workspace symbols. Empty otherwise.
+ arg: []const u8 = "",
+ /// where the project starts — the directory of the pane that asked. A
+ /// backend that indexes more than one file walks from here.
+ root: []const u8 = "",
+};
+
+/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way
+/// every other row in a `+Search` buffer is (and the way look.zig parses one).
+pub fn row(
+ gpa: std.mem.Allocator,
+ out: *std.ArrayList(u8),
+ path: []const u8,
+ line: usize,
+ col: usize,
+ text: []const u8,
+) void {
+ out.print(gpa, "{s}:{d}:{d} {s}\n", .{
+ path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"),
+ }) catch {};
+}
+
+/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn
+/// an AST token into a row, so it lives here rather than three times over.
+pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } {
+ const upto = source[0..@min(offset, source.len)];
+ const line = std.mem.count(u8, upto, "\n");
+ const bol = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |i| i + 1 else 0;
+ return .{ .line = line, .col = upto.len - bol };
+}
+
+/// Answer `req`, appending rows to `out`. Runs on a worker thread with no
+/// access to the core: everything it may read is in `req`.
+///
+/// `arena` is freed wholesale when the query returns; `gpa` owns only what
+/// goes into `out`. Errors are not reported — a backend that cannot answer
+/// appends nothing, and the core treats "no rows" as "no result", which is
+/// also what a language server that is still starting up looks like.
+pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.ArrayList(u8)) void {
+ // ponytail: the base tree has no backend on purpose — this is the seam the
+ // competing implementations fill in, and an empty answer is a legal one.
+ _ = gpa;
+ _ = arena;
+ _ = req;
+ _ = out;
+}
+
+/// What this backend can actually answer, for the evaluation harness and for
+/// the core (a Kind that is not supported never leaves the keymap). An
+/// implementation narrows this to what it really does — claiming a feature it
+/// does not have shows up immediately in the harness's matrix.
+pub const supports: std.EnumSet(Kind) = .initEmpty();
+
+/// Name shown by the harness and in `SPC ?`. Each implementation renames it.
+pub const backend_name = "none";
diff --git a/src/pardes.zig b/src/pardes.zig
index e29c3662..32de2f1b 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -17,6 +17,7 @@ const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
+pub const lsp = @import("lsp.zig");
const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0;
const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
@@ -99,6 +100,21 @@ const Builtin = enum {
Up,
Right,
Toggleterm,
+ // The language group. Every one of these is a helix `<space>` command and
+ // keeps helix's key; the pardes builtins that used to sit on k/d/s moved
+ // (see leader_path). They are builtins rather than bare keys for the same
+ // reason Save is one: the word is executable wherever it appears, so a
+ // middle-click on `Hover` in a tag does what SPC k does. The five GOTOS
+ // are not here — helix binds them under `g` as motions, and a motion has
+ // no business being a word you can click.
+ Hover,
+ Rename,
+ CodeAction,
+ SelectRefs,
+ Symbols,
+ WsSymbols,
+ Diagnostics,
+ WsDiagnostics,
};
/// SPC leader: ONE key path per builtin, the whole remapping surface. An
@@ -108,14 +124,29 @@ const Builtin = enum {
/// lists what the prefix typed so far can still reach.
const leader_path = std.EnumArray(Builtin, []const u8).init(.{
.Help = "?",
- .Del = "d",
- .Kill = "k",
+ // HELIX OWNS THESE EIGHT LETTERS. The LSP commands keep the exact keys
+ // helix gives them, which is the whole point of pardes's modal layer being
+ // helix's — a muscle-memory map with three exceptions is not a map. What
+ // moved is ours: Kill k->q, Del d->wc (it closes a window, and helix's
+ // window group already spells close `c`), Dump/Restore s?->f? (writing and
+ // reading a session file is a FILE operation), Tutor ht->T (freeing `h`).
+ .Hover = "k",
+ .Rename = "r",
+ .CodeAction = "a",
+ .SelectRefs = "h",
+ .Symbols = "s",
+ .WsSymbols = "S",
+ .Diagnostics = "d",
+ .WsDiagnostics = "D",
+ .Kill = "q",
+ .Tutor = "T",
// the `f` file group (spacemacs): Save left vim's `w` to join Find here,
// which frees `w` for the window group (SPC w h/j/k/l) to move into.
.Save = "fs",
.Find = "ff",
.Grep = "fg",
- .Tutor = "ht",
+ .Dump = "fd",
+ .Restore = "fr",
.Newcol = "cn",
.Delcol = "cd",
.Debug = "td",
@@ -127,16 +158,16 @@ const leader_path = std.EnumArray(Builtin, []const u8).init(.{
.Petscii = "tp",
.Palette = "tl",
.Ascii = "ta",
- .Dump = "sd",
- .Restore = "sr",
// the `w` window group `Save` vacated: the four directional focus moves
// the Ctrl-w prefix does, spelled h/j/k/l because focus IS a motion, plus
- // `t` for the file<->terminal hop.
+ // `t` for the file<->terminal hop and `c` for close (helix's own spelling
+ // in its window mode, and where Del landed when `d` went to diagnostics).
.Left = "wh",
.Down = "wj",
.Up = "wk",
.Right = "wl",
.Toggleterm = "wt",
+ .Del = "wc",
});
/// The leader tree, flattened: one row per builtin SORTED BY PATH, so every
@@ -147,6 +178,9 @@ const leader_path = std.EnumArray(Builtin, []const u8).init(.{
/// rendering, padded here so Help is a filter over an array of lines.
const Leader = struct { path: []const u8, cmd: Builtin, line: []const u8 };
const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: {
+ // the insertion sort below is O(n²) comptime branches, and n grew with the
+ // language group
+ @setEvalBranchQuota(8000);
var rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = undefined;
// the widest spaced-out path ("h t" = 3), so the names line up
var keyw: usize = 0;
@@ -529,6 +563,12 @@ pub const Event = union(enum) {
resize: struct { cols: u16, rows: u16 },
output: struct { pane: u8, bytes: []const u8 },
eof: struct { pane: u8 },
+ /// a language query the shell ran on a worker has finished. `rows` is
+ /// `+Search`-format text (see lsp.zig) and is borrowed for this call only,
+ /// exactly like `output` bytes. An id the core no longer recognises is a
+ /// stale answer (the pane was closed, or a newer query superseded it) and
+ /// is dropped.
+ lsp_resp: struct { id: u32, rows: []const u8 },
paste: []const u8,
pinch: f32,
touch_scroll: f32,
@@ -551,6 +591,11 @@ pub const Effect = union(enum) {
write_dump,
/// the yank register changed; the shell reads it off the core (OSC 52 out)
set_clipboard,
+ /// answer a language query OFF the event loop and post the rows back as an
+ /// `lsp_resp` Event. The shell reads the file's path and content off the
+ /// core (like save_file) and must SNAPSHOT them before the worker starts —
+ /// the core keeps editing while this is in flight.
+ lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) },
quit,
fn Buf(comptime n: usize) type {
@@ -726,6 +771,10 @@ pub const Pane = struct {
search_at: u16 = 0,
search_pane: ?usize = null,
search_row: ?usize = null,
+ /// which language query filled search_pane, or null when a plain `/`,
+ /// Find or Grep did. Read only by `]d`/`[d`, which step the diagnostics
+ /// list when it is already up and ask for one when it is not.
+ search_kind: ?lsp.Kind = null,
/// the editable tag tail: seeded with the default builtins on first touch;
/// edited in place by a modal one-line editor sharing the pane's mode
tag_tail: std.ArrayList(u8) = .empty,
@@ -962,6 +1011,14 @@ pub const Pardes = struct {
/// press during an active middle select-drag; consumed on middle release
chord_arg: ?[]u8 = null,
+ /// The one language query in flight. ONE, deliberately: every one of these
+ /// is a keystroke the user is waiting on, so a second press means "I meant
+ /// this one" — the id bump makes the older answer stale and lspResponse
+ /// drops it. A queue would only buy the right to render an answer nobody
+ /// is waiting for any more.
+ lsp_seq: u32 = 0,
+ lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize } = null,
+
/// pending effects, drained by the shell after each update. Growable: one
/// update can emit unbounded writes (a big selection executed is chunked
/// into 64-byte write effects) — a fixed ring overflows and panics.
@@ -1206,6 +1263,7 @@ pub const Pardes = struct {
pane.vt.screens.active.scroll(.active);
},
.eof => |e| p.removePane(e.pane),
+ .lsp_resp => |r| p.lspResponse(r.id, r.rows),
.key => |key| p.handleKey(key),
.mouse => |m| p.handleMouse(m),
.paste => |bytes| {
@@ -2368,6 +2426,14 @@ pub const Pardes = struct {
return pointMove(pane, pl, text, range, @min(ls + (cnt - 1), modal.hxLineEndIdx(text, line)));
}
if (is(key, 't') or is(key, 'c') or is(key, 'b')) return gotoWindow(pane, pl, text, range, key.cp, cnt);
+ // helix's five LSP gotos, all under `g` and nowhere else. They ask
+ // and return; the answer arrives later as an lsp_resp Event and
+ // either jumps or opens a list (lspResponse).
+ if (is(key, 'd')) return p.lspRequest(p.active, .definition, "");
+ if (key.cp == 'D' and !key.ctrl and !key.alt) return p.lspRequest(p.active, .declaration, "");
+ if (is(key, 'y')) return p.lspRequest(p.active, .type_definition, "");
+ if (is(key, 'i')) return p.lspRequest(p.active, .implementation, "");
+ if (is(key, 'r')) return p.lspRequest(p.active, .references, "");
return;
}
if (pane.pending == 'z') {
@@ -2453,6 +2519,27 @@ pub const Pardes = struct {
return setPaneRange(pane, pl, text, r2, false);
}
if (is(key, ' ')) return p.addNewline(pane, fwd, cnt);
+ // ]d / [d: step the diagnostics list, asking for one if it is not
+ // up yet. The list is an ordinary results buffer, so "step" is the
+ // n/N stepper — `d` differs from `n` only in what it guarantees is
+ // showing. ]D / [D jump to the last/first, helix's pair.
+ if (is(key, 'd')) {
+ if (pane.search_kind == .diagnostics and p.searchStep(p.active, if (fwd) 1 else -1)) return;
+ return p.lspRequest(p.active, .diagnostics, "");
+ }
+ if (key.cp == 'D' and !key.ctrl and !key.alt) {
+ if (pane.search_kind != .diagnostics) return p.lspRequest(p.active, .diagnostics, "");
+ if (!fwd) { // [D = first
+ pane.search_row = null;
+ _ = p.searchStep(p.active, 1);
+ return;
+ }
+ const rp = p.panes[pane.search_pane orelse return] orelse return;
+ const rf = rp.file orelse return;
+ pane.search_row = std.mem.count(u8, rf.content, "\n"); // one past the last
+ _ = p.searchStep(p.active, -1);
+ return;
+ }
return;
}
// prefix setters (the count survives into the continuation)
@@ -2578,6 +2665,9 @@ pub const Pardes = struct {
if (is(key, 'J')) return p.normalJoin(pane);
if (is(key, '>')) return p.normalIndent(pane, cnt, true);
if (is(key, '<')) return p.normalIndent(pane, cnt, false);
+ // helix's `=` (format_selections) — its neighbour on the keyboard and
+ // in the keymap. Unbound in pardes until now.
+ if (is(key, '=')) return p.lspRequest(p.active, .format, "");
if (isC(key, 'a')) return p.normalAdjustNumber(pane, @intCast(cnt));
if (isC(key, 'x')) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt)));
if (is(key, 'u')) return p.doUndo(pane);
@@ -2624,6 +2714,10 @@ pub const Pardes = struct {
const search_marker = " /";
const find_marker = " Find /";
const grep_marker = " Grep /";
+ // the two language queries that need a word typed: same tag input, same
+ // Enter, and the marker is still what says which one is armed
+ const rename_marker = " Rename /";
+ const symbol_marker = " WsSymbols /";
const Search = enum { text, find, grep };
/// `/` (and the Find builtin) on any pane: type the pattern into the tag
@@ -2656,6 +2750,8 @@ pub const Pardes = struct {
const tail = pane.tag_tail.items;
const armed = tail[@min(pane.search_at, tail.len)..];
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
+ if (std.mem.startsWith(u8, armed, rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
+ if (std.mem.startsWith(u8, armed, symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]);
const kind: Search = if (std.mem.startsWith(u8, armed, find_marker))
.find
else if (std.mem.startsWith(u8, armed, grep_marker))
@@ -2755,6 +2851,7 @@ pub const Pardes = struct {
p.active = id;
pane.search_pane = free;
pane.search_row = null;
+ pane.search_kind = null;
}
/// n/N: step to the next/previous row of this pane's results buffer and
@@ -2794,6 +2891,76 @@ pub const Pardes = struct {
return true;
}
+ /// Ask the backend something about the symbol under the cursor. Only a
+ /// real file can be asked: a terminal's rows are a program's output and an
+ /// output buffer is our own text, neither of which has a language behind
+ /// it. Unsupported kinds never get here (the keymap drops them), so a
+ /// backend that answers nothing simply never opens a buffer.
+ fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void {
+ if (!lsp.supports.contains(kind)) return;
+ const pane = p.panes[id] orelse return;
+ const f = pane.file orelse return;
+ if (f.output) return;
+ if (arg.len > 128) return; // the effect's arg is a Buf(128)
+ const off = modal.hxOff(f.content, .{
+ .row = @intCast(@max(0, pane.cur_row)),
+ .col = @intCast(@max(0, pane.cur_col)),
+ });
+ p.lsp_seq +%= 1;
+ p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id };
+ p.emit(.{ .lsp = .{
+ .id = p.lsp_seq,
+ .kind = kind,
+ .pane = @intCast(id),
+ .offset = @intCast(off),
+ .arg = .from(arg),
+ } });
+ }
+
+ /// A worker answered. Rows are `+Search` format with ABSOLUTE paths, so
+ /// both dispositions below are the ordinary look path:
+ /// one row, a goto -> jump straight there (helix jumps on a single
+ /// location and shows a picker on several)
+ /// anything else -> an output buffer, which n/N already steps. That
+ /// buffer IS the picker; there was never one to write.
+ pub fn lspResponse(p: *Pardes, id: u32, rows: []const u8) void {
+ const w = p.lsp_wait orelse return;
+ if (w.id != id) return; // superseded by a newer press, or the pane died
+ p.lsp_wait = null;
+ const pane = p.panes[w.pane] orelse return;
+ if (rows.len == 0) return;
+
+ const nrows = std.mem.count(u8, rows, "\n");
+ if (w.kind.jumpsWhenSingle() and nrows == 1) {
+ const ln = std.mem.trimEnd(u8, rows, "\n");
+ var hi: usize = 0;
+ while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1;
+ // push the jumplist the way helix does before navigating away
+ pane.pinCursor();
+ return p.actOnSelection(.right, w.pane, ln[0..hi], null);
+ }
+
+ const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ const content = p.gpa.dupe(u8, rows) catch return;
+ const free = p.freeSlot() orelse {
+ p.gpa.free(content);
+ return;
+ };
+ const np = p.openOutputView(free, dir, w.kind.bufferName(), content) catch {
+ p.gpa.free(content);
+ return;
+ };
+ p.placeDoc(w.pane, free, np);
+ p.active = w.pane;
+ // prose is not a list of locations: n/N over a hover blurb would step
+ // to nowhere, so only location buffers arm the stepper.
+ if (std.mem.eql(u8, w.kind.bufferName(), "+Search")) {
+ pane.search_pane = free;
+ pane.search_row = null;
+ pane.search_kind = w.kind;
+ }
+ }
+
/// n/N on a terminal pane: a MOTION over lookable tokens. Select the
/// next/prev whitespace-separated token that look.resolve can turn into a
/// file/dir (several per line: an ls row hops big.txt -> plain.txt), park
@@ -5090,6 +5257,25 @@ pub const Pardes = struct {
p.quit = true;
p.emit(.quit);
},
+ // The language builtins are all one call: ask, and let the answer
+ // land in lspResponse. Nothing here blocks or knows what a backend
+ // is — swapping backends changes lsp.query and not one line below.
+ .Hover => p.lspRequest(id, .hover, ""),
+ .CodeAction => p.lspRequest(id, .code_action, ""),
+ .SelectRefs => p.lspRequest(id, .select_refs, ""),
+ .Symbols => p.lspRequest(id, .document_symbols, ""),
+ .Diagnostics => p.lspRequest(id, .diagnostics, ""),
+ .WsDiagnostics => p.lspRequest(id, .workspace_diagnostics, ""),
+ // The two that need a word from the user, handled exactly the way
+ // Find and Grep are: an argument means run it now (a selection
+ // chorded onto the name), no argument arms the tag input and Enter
+ // submits (submitSearch).
+ .Rename, .WsSymbols => {
+ const kind: lsp.Kind = if (b == .Rename) .rename else .workspace_symbols;
+ const a = std.mem.trim(u8, arg orelse "", " \t\r\n");
+ if (a.len > 0) return p.lspRequest(id, kind, a);
+ p.startSearch(pane, if (b == .Rename) rename_marker else symbol_marker);
+ },
.Save => {
// an output buffer has no file behind it — nothing to write
if (pane.file) |f| if (!f.output) p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
diff --git a/src/tty.zig b/src/tty.zig
index 2326924c..818d8b48 100644
--- a/src/tty.zig
+++ b/src/tty.zig
@@ -34,10 +34,34 @@ pub const Command = struct {
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
paste: []const u8,
+ /// a language query finished on a worker; rows are gpa-owned
+ lsp_done: struct { id: u32, rows: []u8 },
} = .nop;
};
const Loop = vaxis.Loop(@TypeOf(Command.value));
+/// One language query, owned by the worker that runs it. Everything the
+/// backend may read is copied in here before the worker starts: the core goes
+/// on editing the moment the effect is drained, so a borrowed slice would be a
+/// use-after-free the length of one keystroke.
+const LspJob = struct {
+ id: u32,
+ kind: pardes.lsp.Kind,
+ offset: u32,
+ path: []u8,
+ source: [:0]u8,
+ arg: []u8,
+ root: []u8,
+
+ fn free(j: *LspJob, gpa: std.mem.Allocator) void {
+ gpa.free(j.path);
+ gpa.free(j.source);
+ gpa.free(j.arg);
+ gpa.free(j.root);
+ gpa.destroy(j);
+ }
+};
+
const Pty = struct {
file: std.Io.File,
pid: posix.pid_t,
@@ -101,6 +125,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// per-slot spawn generation: a reused pane id ignores the old shell's
// late pty_eof (which would otherwise close the NEW pty on that slot)
var gens: [pardes.MAX_PANES]u32 = @splat(0);
+ // the single in-flight language query (see the .lsp effect)
+ var lsp_task: ?std.Io.Future(anyerror!void) = null;
var loop: Loop = .init(io, &tty, &vx);
defer {
// reap the reader tasks (cancel interrupts a blocked read) before
@@ -112,16 +138,23 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
_ = libc.close(pt.file.handle);
slot.* = null;
};
+ // join the query worker BEFORE the drain below, or its late post
+ // lands in a queue nobody empties again and the rows leak
+ if (lsp_task) |*t| {
+ t.cancel(io) catch {};
+ lsp_task = null;
+ }
while (loop.tryEvent() catch null) |ev| switch (ev) {
.pty_read => |pr| gpa.free(pr.bytes),
.paste => |b| gpa.free(@constCast(b)),
+ .lsp_done => |d| gpa.free(d.rows),
else => {},
};
}
// Perform the initial spawns BEFORE any worker thread exists: forkpty from
// a multithreaded process can wedge the child before exec.
- drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, false);
+ drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, false);
try loop.start();
defer loop.stop();
@@ -205,6 +238,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
core.update(.{ .paste = bytes });
gpa.free(@constCast(bytes));
},
+ .lsp_done => |d| {
+ core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } });
+ gpa.free(d.rows);
+ // the worker is finished; join it so its future does not
+ // leak (same contract as pty_eof above)
+ if (lsp_task) |*t| {
+ t.await(io) catch {};
+ lsp_task = null;
+ }
+ },
}
batch += 1;
if (stop or output or batch >= 64) break;
@@ -213,7 +256,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
tz_event.end();
if (stop) break;
- drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, true);
+ drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, true);
if (core.quit) break;
// Restore builtin: swap in a core rebuilt from the dump; the live
@@ -302,6 +345,7 @@ fn drainEffects(
loop: *Loop,
vx: *vaxis.Vaxis,
tty: *vaxis.Tty,
+ lsp_task: *?std.Io.Future(anyerror!void),
threads_ok: bool,
) void {
while (core.nextEffect()) |effect| switch (effect) {
@@ -370,10 +414,80 @@ fn drainEffects(
// mirror the core's yank register out via OSC 52
if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {};
},
+ .lsp => |q| {
+ if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet
+ const pane = core.panes[q.pane] orelse continue;
+ const f = pane.file orelse continue;
+ // snapshot everything the worker may read (see LspJob)
+ const job = gpa.create(LspJob) catch continue;
+ job.* = .{
+ .id = q.id,
+ .kind = q.kind,
+ .offset = q.offset,
+ .path = gpa.dupe(u8, f.path) catch {
+ gpa.destroy(job);
+ continue;
+ },
+ .source = gpa.dupeZ(u8, f.content) catch {
+ gpa.free(job.path);
+ gpa.destroy(job);
+ continue;
+ },
+ .arg = gpa.dupe(u8, q.arg.slice()) catch {
+ gpa.free(job.path);
+ gpa.free(job.source);
+ gpa.destroy(job);
+ continue;
+ },
+ .root = gpa.dupe(u8, std.fs.path.dirname(f.path) orelse "/") catch {
+ gpa.free(job.path);
+ gpa.free(job.source);
+ gpa.free(job.arg);
+ gpa.destroy(job);
+ continue;
+ },
+ };
+ // ponytail: ONE query in flight, so one future slot. Replacing it
+ // cancels-then-joins the previous worker, which for a backend that
+ // ignores cancellation means waiting out a query the user already
+ // abandoned. Queries are milliseconds; make this a real pool the
+ // day a backend takes long enough to notice.
+ if (lsp_task.*) |*old| {
+ old.cancel(io) catch {};
+ lsp_task.* = null;
+ }
+ lsp_task.* = io.concurrent(lspWorker, .{ gpa, job, loop }) catch {
+ job.free(gpa);
+ continue;
+ };
+ },
.quit => {},
};
}
+/// Answer a language query off the event loop and post the rows back. This is
+/// the whole async execution model: the same shape as readPty — do the slow
+/// thing on a worker, hand the result to the loop as an event, let the core
+/// stay a state machine that never blocks.
+fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void {
+ defer job.free(gpa);
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ pardes.lsp.query(gpa, arena.allocator(), .{
+ .kind = job.kind,
+ .path = job.path,
+ .source = job.source,
+ .offset = job.offset,
+ .arg = job.arg,
+ .root = job.root,
+ }, &out);
+ const rows = gpa.dupe(u8, out.items) catch return;
+ loop.postEvent(.{ .lsp_done = .{ .id = job.id, .rows = rows } }) catch gpa.free(rows);
+ return;
+}
+
/// Consume SIGWINCH synchronously (it is blocked in every thread) and post
/// the new size as a winsize event from normal thread context — the one place
/// vaxis's Io-backed queue is safe to touch on a resize.
diff --git a/test/lspbench.zig b/test/lspbench.zig
new file mode 100644
index 00000000..73b82e2b
--- /dev/null
+++ b/test/lspbench.zig
@@ -0,0 +1,264 @@
+//! The language-backend scoreboard: one binary, three numbers.
+//!
+//! zig build lspbench -- latency + feature matrix over pardes's own src/
+//! zig build lspbench -- --json -- the same, machine-readable
+//!
+//! Every competing backend links the same harness against the same corpus and
+//! the same query list, so the columns mean the same thing across all of them.
+//! What it measures, in the order the evaluation weighs it:
+//!
+//! FEATURES which lsp.Kind values actually return rows. Claiming support in
+//! `lsp.supports` and returning nothing is a FALSE claim and shows
+//! up as `claimed-empty` — the harness trusts results, not flags.
+//! LATENCY cold (first query, index build included) and warm (median of N)
+//! per kind. Cold is what a keypress costs the first time; warm is
+//! what it costs forever after. Both matter and they differ by
+//! orders of magnitude for an indexing backend.
+//! MEMORY peak RSS delta across the run, read from /proc.
+//!
+//! Lines of code is not measured here — it is `jj diff --stat` against the
+//! base commit, which is the honest number (a backend that vendors a library
+//! pays for what it vendors only in build time, not in code we maintain).
+const std = @import("std");
+const libc = std.c;
+const lsp = @import("pardes").lsp;
+
+// 0.16 slimmed std.fs (no cwd(), no realpathAlloc) and this repo goes through
+// libc everywhere for exactly that reason — see look.zig.
+extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
+
+/// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig already uses.
+fn nowNs() u64 {
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| @as(u64, @intCast(ts.nsec));
+}
+
+pub const std_options: std.Options = .{ .log_level = .err };
+
+/// One probe. The corpus is pardes's own source: real Zig, in this repo, that
+/// every implementation can reach without a fixture tree to keep in sync.
+///
+/// The cursor is pinned by SYMBOL, not by line — the needle is searched for at
+/// startup — so editing pardes.zig cannot silently rot the bench into probing
+/// a blank line and calling the result "no support".
+///
+/// `expect` is a substring the rows must contain for the probe to count as
+/// CORRECT rather than merely non-empty. A backend that returns a plausible
+/// wrong location scores worse than one that returns nothing, and only this
+/// field can tell those two apart.
+const Anchor = struct {
+ file: []const u8,
+ /// the exact source text to put the cursor on; the FIRST occurrence wins
+ needle: []const u8,
+ /// byte offset into `needle` where the cursor sits
+ at: u32 = 0,
+ kind: lsp.Kind,
+ expect: []const u8 = "",
+};
+
+const anchors = [_]Anchor{
+ // a call to a function defined in the same file
+ .{ .file = "src/pardes.zig", .needle = "modal.hxLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" },
+ // a plain local/decl reference within one file
+ .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" },
+ // a std reference: needs the zig lib dir, which is the first thing a
+ // single-file backend cannot do
+ .{ .file = "src/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" },
+ // hover over the same symbol
+ .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" },
+ // the file's own symbols
+ .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .document_symbols, .expect = "Kind" },
+ .{ .file = "src/modal.zig", .needle = "pub fn ", .at = 7, .kind = .document_symbols, .expect = "" },
+ // references to a symbol used in several places
+ .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .expect = "" },
+ // diagnostics on a file that should have none
+ .{ .file = "src/lsp.zig", .needle = "const std", .at = 6, .kind = .diagnostics, .expect = "" },
+ // the remaining kinds, probed once each so the matrix is complete
+ .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" },
+ .{ .file = "src/lsp.zig", .needle = "out: *std.ArrayList(u8)", .at = 10, .kind = .type_definition, .expect = "" },
+ .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" },
+ .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" },
+ .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .format, .expect = "" },
+ .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .code_action, .expect = "" },
+ .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" },
+ .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" },
+ .{ .file = "src/lsp.zig", .needle = "const std", .at = 6, .kind = .workspace_diagnostics, .expect = "" },
+};
+
+const Result = struct {
+ kind: lsp.Kind,
+ /// no rows came back
+ empty: bool = true,
+ /// rows came back AND contained `expect` (or nothing was expected)
+ correct: bool = false,
+ rows: usize = 0,
+ cold_us: u64 = 0,
+ warm_us: u64 = 0,
+};
+
+const warm_iters = 20;
+
+pub fn main(init: std.process.Init) !void {
+ // page_allocator like the other harnesses: this measures the BACKEND, and
+ // a debug allocator's bookkeeping would land in every number.
+ const gpa = std.heap.page_allocator;
+
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ var json = false;
+ var root: []const u8 = ".";
+ for (args[1..]) |a| {
+ if (std.mem.eql(u8, a, "--json")) json = true else root = a;
+ }
+
+ const rss0 = rssKib();
+ var results: std.ArrayList(Result) = .empty;
+ defer results.deinit(gpa);
+
+ for (anchors) |an| {
+ const path = try std.fs.path.join(gpa, &.{ root, an.file });
+ defer gpa.free(path);
+ const src = readZ(gpa, path) catch continue;
+ defer gpa.free(src);
+ const off = std.mem.indexOf(u8, src, an.needle) orelse {
+ std.debug.print("bench: anchor not found in {s}: `{s}`\n", .{ an.file, an.needle });
+ continue;
+ };
+ var realbuf: [4096]u8 = undefined;
+ var pz: [4096:0]u8 = undefined;
+ const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{path}, 0) catch continue;
+ const abs = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse continue);
+ const dir = std.fs.path.dirname(abs) orelse "/";
+ const req: lsp.Req = .{
+ .kind = an.kind,
+ .path = abs,
+ .source = src,
+ .offset = @intCast(off + an.at),
+ .arg = if (an.kind == .rename) "renamed_by_bench" else if (an.kind == .workspace_symbols) "Kind" else "",
+ .root = dir,
+ };
+
+ var r: Result = .{ .kind = an.kind };
+ // COLD: whatever the backend has to build the first time counts.
+ r.cold_us = runOnce(gpa, req, &r);
+ // WARM: median of warm_iters, so one scheduling hiccup cannot flatter
+ // or damn a backend.
+ var samples: [warm_iters]u64 = undefined;
+ for (&samples) |*s| {
+ var throwaway: Result = .{ .kind = an.kind };
+ s.* = runOnce(gpa, req, &throwaway);
+ }
+ std.mem.sort(u64, &samples, {}, std.sort.asc(u64));
+ r.warm_us = samples[warm_iters / 2];
+ r.correct = !r.empty and (an.expect.len == 0 or blk: {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ lsp.query(gpa, arena.allocator(), req, &out);
+ break :blk std.mem.indexOf(u8, out.items, an.expect) != null;
+ });
+ try results.append(gpa, r);
+ }
+
+ const rss = rssKib() -| rss0;
+ if (json) reportJson(results.items, rss) else reportText(results.items, rss);
+}
+
+fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, r: *Result) u64 {
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ const t0 = nowNs();
+ lsp.query(gpa, arena.allocator(), req, &out);
+ const ns = nowNs() -| t0;
+ if (out.items.len > 0) {
+ r.empty = false;
+ r.rows = std.mem.count(u8, out.items, "\n");
+ }
+ return ns / 1000;
+}
+
+fn reportText(rs: []const Result, rss: u64) void {
+ const o = std.debug.print;
+ o("backend: {s}\n\n", .{lsp.backend_name});
+ o("{s:<24} {s:>9} {s:>7} {s:>10} {s:>10} {s}\n", .{ "kind", "supports", "rows", "cold(us)", "warm(us)", "verdict" });
+ o("{s}\n", .{"-" ** 78});
+ var claimed: usize = 0;
+ var working: usize = 0;
+ var lies: usize = 0;
+ for (rs) |r| {
+ const sup = lsp.supports.contains(r.kind);
+ if (sup) claimed += 1;
+ const verdict: []const u8 = if (!sup and r.empty)
+ "-"
+ else if (sup and r.empty)
+ "CLAIMED-EMPTY"
+ else if (!sup and !r.empty)
+ "unclaimed-works"
+ else if (r.correct)
+ "ok"
+ else
+ "rows-but-wrong";
+ if (sup and !r.empty and r.correct) working += 1;
+ if (sup and r.empty) lies += 1;
+ o("{s:<24} {s:>9} {d:>7} {d:>10} {d:>10} {s}\n", .{
+ @tagName(r.kind), if (sup) "yes" else "no", r.rows, r.cold_us, r.warm_us, verdict,
+ });
+ }
+ o("\nclaimed {d}, verified working {d}, false claims {d}\n", .{ claimed, working, lies });
+ o("peak rss delta: {d} KiB\n", .{rss});
+}
+
+fn reportJson(rs: []const Result, rss: u64) void {
+ const o = std.debug.print;
+ o("{{\"backend\":\"{s}\",\"rss_kib\":{d},\"probes\":[", .{ lsp.backend_name, rss });
+ for (rs, 0..) |r, i| {
+ if (i > 0) o(",", .{});
+ o("{{\"kind\":\"{s}\",\"supports\":{},\"rows\":{d},\"correct\":{},\"cold_us\":{d},\"warm_us\":{d}}}", .{
+ @tagName(r.kind), lsp.supports.contains(r.kind), r.rows, r.correct, r.cold_us, r.warm_us,
+ });
+ }
+ o("]}}\n", .{});
+}
+
+fn readZ(gpa: std.mem.Allocator, path: []const u8) ![:0]u8 {
+ var pathbuf: [4096]u8 = undefined;
+ const path_z = try std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0);
+ const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(gpa);
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &chunk, chunk.len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ }
+ return buf.toOwnedSliceSentinel(gpa, 0);
+}
+
+/// VmHWM from /proc/self/status — the peak, not the current, so a backend that
+/// frees its index before returning still pays for having built it.
+fn rssKib() u64 {
+ const fd = libc.open("/proc/self/status", .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return 0;
+ defer _ = libc.close(fd);
+ var buf: [4096]u8 = undefined;
+ const n = libc.read(fd, &buf, buf.len);
+ if (n <= 0) return 0;
+ var it = std.mem.tokenizeScalar(u8, buf[0..@intCast(n)], '\n');
+ while (it.next()) |line| {
+ if (!std.mem.startsWith(u8, line, "VmHWM:")) continue;
+ var t = std.mem.tokenizeAny(u8, line["VmHWM:".len..], " \t");
+ const v = t.next() orelse return 0;
+ return std.fmt.parseInt(u64, v, 10) catch 0;
+ }
+ return 0;
+}
diff --git a/test/snapshots/chordcut.golden b/test/snapshots/chordcut.golden
index be00dc57..6f2da745 100644
--- a/test/snapshots/chordcut.golden
+++ b/test/snapshots/chordcut.golden
@@ -339,21 +339,21 @@
|27: 0-1 d,#444444, 2-6 #626262,#080808, 7-29 d,#080808, 30-31 d,#444444, 32-59 #bdbdbd,#080808, 60-61 d,#444444, 62-89 #bdbdbd,#080808,
|28: 0-1 d,#444444, 2-6 #626262,#080808, 7-29 d,#080808, 30-31 d,#444444, 32-59 #bdbdbd,#080808, 60-61 d,#444444, 62-89 #bdbdbd,#080808,
|29: 0-1 d,#444444, 2-6 #626262,#080808, 7-29 d,#080808, 30-31 d,#444444, 32-59 #bdbdbd,#080808, 60-61 d,#444444, 62-89 #bdbdbd,#080808,
-== snap ttyprobe grid=90x30 cursor=9,11
+== snap ttyprobe grid=90x30 cursor=44,13
|Kill Newcol Tutor Debug NextColor Dump Find Grep
-| NOR /tmp/pardes-snap/chordcu NOR /tmp/pardes-snap/chordcu NOR /tmp/pardes-snap/chordcu
-| 1 line 1
+| NOR /tmp/pardes-snap/chordcu TTY /tmp/pardes-snap/chordcu NOR /tmp/pardes-snap/chordcu
+| 1 line 1 $ ls
| 2 2echo B''OO g.txt f.txt g.txt f.txt g.txt
-| 3 line 3
-| 4 line 4line
-| 5 lline 5
-| 6 canon -echo; printf '\x f.txt
-| 7 BOO
-|
-|
-|
-|
-|
+| 3 line 3 $
+| 4 line 4line $
+| 5 lline 5 $
+| 6 $ echo B''OO
+| BOO
+| $ stty -icanon -echo; printf
+| '\x1b[?2004h\x1b[?1000h\x1b
+| [?1006h'; cat -v
+| ^[[<0;8;10M^[[<0;8;10m^[[200
+| ~line^[[201~
|
|
| NOR /tmp/pardes-snap/chordcu NOR /tmp/pardes-snap/chordcu
@@ -370,23 +370,23 @@
|
|
|
-== snap ttyprompt grid=90x30 cursor=9,11
+== snap ttyprompt grid=90x30 cursor=38,15
|Kill Newcol Tutor Debug NextColor Dump Find Grep
-| NOR /tmp/pardes-snap/chordcu NOR /tmp/pardes-snap/chordcu NOR /tmp/pardes-snap/chordcu
-| 1 line 1
+| NOR /tmp/pardes-snap/chordcu TTY /tmp/pardes-snap/chordcu NOR /tmp/pardes-snap/chordcu
+| 1 line 1 $ ls
| 2 2echo B''OO g.txt f.txt g.txt f.txt g.txt
-| 3 line 3
-| 4 line 4line
-| 5 lline 5
-| 6 canon -echo; printf '\x f.txt
-| 7 BOO
-|
-|
-|
-|
-|
-|
-|
+| 3 line 3 $
+| 4 line 4line $
+| 5 lline 5 $
+| 6 $ echo B''OO
+| BOO
+| $ stty -icanon -echo; printf
+| '\x1b[?2004h\x1b[?1000h\x1b
+| [?1006h'; cat -v
+| ^[[<0;8;10M^[[<0;8;10m^[[200
+| ~line^[[201~
+| $
+| $ line
| NOR /tmp/pardes-snap/chordcu NOR /tmp/pardes-snap/chordcu
| 1 line 1
| 2 line 2 f.txt g.txt
diff --git a/test/snapshots/chordcut.snap b/test/snapshots/chordcut.snap
index a412b57e..a1b65a4d 100644
--- a/test/snapshots/chordcut.snap
+++ b/test/snapshots/chordcut.snap
@@ -118,18 +118,18 @@ snapstyle clickdismiss
# tty paste chord: a mouse-listening app gets the click forwarded (SGR,
# body-relative) then the register between bracketed-paste markers —
# cat -v prints the received bytes visibly
-press left 5 11
-release left 5 11
+press left 35 11
+release left 35 11
stable 400 5000
key c-b
stable 600 8000
text stty -icanon -echo; printf '\x1b[?2004h\x1b[?1000h\x1b[?1006h'; cat -v
key enter
stable 700 8000
-press left 10 12
-press right 10 12
-release right 10 12
-release left 10 12
+press left 40 12
+press right 40 12
+release right 40 12
+release left 40 12
stable 700 8000
snap ttyprobe
# a plain prompt (mouse modes off) gets no click bytes — just the paste;
@@ -139,9 +139,9 @@ stable 600 8000
text stty sane; printf '\x1b[?1000l\x1b[?1006l'
key enter
stable 600 8000
-press left 10 12
-press right 10 12
-release right 10 12
-release left 10 12
+press left 40 12
+press right 40 12
+release right 40 12
+release left 40 12
stable 700 8000
snap ttyprompt
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 934573be..5215b6ae 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -39,9 +39,9 @@
|
|
|
-== snap spc-h grid=100x40 cursor=7,2
+== snap spc-f grid=100x40 cursor=7,2
|Kill Newcol Tutor Debug NextColor Dump Find Grep
-| NOR /tmp/pardes-snap/leader/cwd/cmds.txt S SPC h NOR /tmp/pardes-snap/leader/cwd Del
+| NOR /tmp/pardes-snap/leader/cwd/cmds.txt S SPC f NOR /tmp/pardes-snap/leader/cwd Del
| 1 Help
| cmds.txt
|
@@ -189,20 +189,20 @@
| 2 or execute the name anywhere
| 3
| 4 SPC ? Help
-| 5 SPC c d Delcol
-| 6 SPC c n Newcol
-| 7 SPC d Del
-| 8 SPC f f Find
-| 9 SPC f g Grep
-| 10 SPC f s Save
-| 11 SPC h t Tutor
-| 12 SPC k Kill
-| 13 SPC s d Dump
-| 14 SPC s r Restore
-| 15 SPC t a Ascii
-| 16 SPC t c Colors
-| 17 SPC t d Debug
-| 18 SPC t l Palette
+| 5 SPC D WsDiagnostics
+| 6 SPC S WsSymbols
+| 7 SPC T Tutor
+| 8 SPC a CodeAction
+| 9 SPC c d Delcol
+| 10 SPC c n Newcol
+| 11 SPC d Diagnostics
+| 12 SPC f d Dump
+| 13 SPC f f Find
+| 14 SPC f g Grep
+| 15 SPC f r Restore
+| 16 SPC f s Save
+| 17 SPC h SelectRefs
+| 18 SPC k Hover
== snap help-group grid=100x40 cursor=7,22
|Kill Newcol Tutor Debug NextColor Dump Find Grep
| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del
@@ -226,14 +226,14 @@
|
|
| NOR /tmp/pardes-snap/leader/cwd/+Help Del
-| 1 pardes builtins under SPC h
+| 1 pardes builtins under SPC f
| 2
-| 3 SPC h t Tutor
-| 4
-|
-|
-|
-|
+| 3 SPC f d Dump
+| 4 SPC f f Find
+| 5 SPC f g Grep
+| 6 SPC f r Restore
+| 7 SPC f s Save
+| 8
|
|
|
@@ -267,14 +267,14 @@
| 8
| 9
| NOR /tmp/pardes-snap/leader/cwd/+Help Del
-| 1 pardes builtins under SPC h
+| 1 pardes builtins under SPC f
| 2
-| 3 SPC h t Tutor
-| 4
-|
-|
-|
-|
+| 3 SPC f d Dump
+| 4 SPC f f Find
+| 5 SPC f g Grep
+| 6 SPC f r Restore
+| 7 SPC f s Save
+| 8
|
|
|
@@ -308,14 +308,14 @@
|
|
| NOR /tmp/pardes-snap/leader/cwd/+Help Del
-| 1 pardes builtins under SPC h
+| 1 pardes builtins under SPC f
| 2
-| 3 SPC h t Tutor
-| 4
-|
-|
-|
-|
+| 3 SPC f d Dump
+| 4 SPC f f Find
+| 5 SPC f g Grep
+| 6 SPC f r Restore
+| 7 SPC f s Save
+| 8
|
|
|
@@ -353,20 +353,20 @@
| 2 or execute the name anywhere
| 3
| 4 SPC ? Help
-| 5 SPC c d Delcol
-| 6 SPC c n Newcol
-| 7 SPC d Del
-| 8 SPC f f Find
-| 9 SPC f g Grep
-| 10 SPC f s Save
-| 11 SPC h t Tutor
-| 12 SPC k Kill
-| 13 SPC s d Dump
-| 14 SPC s r Restore
-| 15 SPC t a Ascii
-| 16 SPC t c Colors
-| 17 SPC t d Debug
-| 18 SPC t l Palette
+| 5 SPC D WsDiagnostics
+| 6 SPC S WsSymbols
+| 7 SPC T Tutor
+| 8 SPC a CodeAction
+| 9 SPC c d Delcol
+| 10 SPC c n Newcol
+| 11 SPC d Diagnostics
+| 12 SPC f d Dump
+| 13 SPC f f Find
+| 14 SPC f g Grep
+| 15 SPC f r Restore
+| 16 SPC f s Save
+| 17 SPC h SelectRefs
+| 18 SPC k Hover
== snap help-toggles grid=100x40 cursor=7,22
|Kill Newcol Tutor Debug NextColor Dump Find Grep
| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del
@@ -433,13 +433,13 @@
| NOR /tmp/pardes-snap/leader/cwd/+Help Del
| 1 pardes builtins under SPC w
| 2
-| 3 SPC w h Left
-| 4 SPC w j Down
-| 5 SPC w k Up
-| 6 SPC w l Right
-| 7 SPC w t Toggleterm
-| 8
-|
+| 3 SPC w c Del
+| 4 SPC w h Left
+| 5 SPC w j Down
+| 6 SPC w k Up
+| 7 SPC w l Right
+| 8 SPC w t Toggleterm
+| 9
|
|
|
diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap
index c8e13b8e..e06d5a3c 100644
--- a/test/snapshots/leader.snap
+++ b/test/snapshots/leader.snap
@@ -6,6 +6,10 @@
# pane with no file behind it, like +Search). The paths, the Help lines and
# the execute dispatch are all folded out of ONE enum at comptime — so
# executing the WORD `Help` runs the same builtin the leader does.
+#
+# The single-letter slots k/r/a/h/s/S/d/D belong to helix's LSP commands, so
+# pardes's own builtins live around them: Kill on `q`, Del in the window group
+# as `w c`, Dump/Restore in the file group, Tutor on `T`.
file cmds.txt Help
start 40 100 cmds.txt
wait 8000 Kill Newcol
@@ -15,10 +19,10 @@ stable 700 20000
key space
stable 400 5000
snap spc
-# ... and grows as the path is typed (`h` = the docs group)
-key h
+# ... and grows as the path is typed (`f` = the file group)
+key f
stable 400 5000
-snap spc-h
+snap spc-f
# Esc abandons the sequence: indicator gone, nothing ran, cursor untouched
key esc
settle 100
@@ -27,41 +31,43 @@ snap abandoned
# an UNMAPPED key abandons too, rather than waiting: a typo must not leave the
# next keystroke armed at a builtin that closes panes
key space
-key q
+key z
stable 400 5000
snap unmapped
# executing the WORD `Help` (middle-click) runs the builtin — the enum IS the
# lookup, so a name works anywhere text lives. Every builtin, with its keys.
press middle 9 3
release middle 9 3
-wait 10000 SPC h t
+wait 10000 SPC T
stable 700 15000
snap help-word
-# `SPC h ?`: only the `h` group, rewritten into the SAME +Help buffer
+# `SPC f ?`: only the `f` group, rewritten into the SAME +Help buffer
key space
-key h
+key f
key ?
-wait 10000 under SPC h
+wait 10000 under SPC f
stable 700 15000
snap help-group
-# `SPC h t`: the Tutor builtin, zero arguments. It runs from the +Help pane,
+# `SPC T`: the Tutor builtin, zero arguments. It runs from the +Help pane,
# but that is an output pane and not a document — so the tutor lands under
# cmds.txt, the real doc, with the listing left at the foot of the column.
key space
-key h
-key t
+key T
wait 10000 PARDES TUTOR
stable 700 15000
snap tutor
-# `SPC d`: Del is pane-scoped — it closes the tutor, focus falls back
+# `SPC w c`: Del is pane-scoped — it closes the tutor, focus falls back. It
+# sits in the window group because closing a pane IS a window operation, and
+# because `d` now belongs to the diagnostics picker.
key space
-key d
+key w
+key c
stable 700 15000
snap deleted
# `SPC ?` at the root lists everything again
key space
key ?
-wait 10000 SPC s r
+wait 10000 SPC f r
stable 700 15000
snap help-all
# `SPC t ?`: the toggle group, seven of them now that the image renderer
@@ -73,8 +79,8 @@ key ?
wait 10000 Petscii
stable 700 15000
snap help-toggles
-# `SPC w ?`: the window group, whose five builtins are the tail of the full
-# listing above and fall off the bottom of this pane there
+# `SPC w ?`: the window group — the four focus moves, the terminal hop, and
+# Del. They are the tail of the full listing above and fall off its bottom.
key space
key w
key ?