diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-28 23:30:29 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | f43c1e11b44e2464f0bb0b635e0abcaf3c717e23 (patch) | |
| tree | ddc5e5528ebb59f72d30bef49522f814578e4fce | |
| parent | 65b207c3392c75eac3f2b18a266a6482d8345df1 (diff) | |
| download | pardes-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.zig | 21 | ||||
| -rw-r--r-- | docs/lsp.md | 146 | ||||
| -rw-r--r-- | src/gui.zig | 122 | ||||
| -rw-r--r-- | src/lsp.zig | 141 | ||||
| -rw-r--r-- | src/pardes.zig | 198 | ||||
| -rw-r--r-- | src/tty.zig | 118 | ||||
| -rw-r--r-- | test/lspbench.zig | 264 | ||||
| -rw-r--r-- | test/snapshots/chordcut.golden | 56 | ||||
| -rw-r--r-- | test/snapshots/chordcut.snap | 20 | ||||
| -rw-r--r-- | test/snapshots/leader.golden | 116 | ||||
| -rw-r--r-- | test/snapshots/leader.snap | 38 |
11 files changed, 1110 insertions, 130 deletions
@@ -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 ? |
