From f43c1e11b44e2464f0bb0b635e0abcaf3c717e23 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 28 Jul 2026 23:30:29 -0300 Subject: 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. --- src/gui.zig | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 112 insertions(+), 10 deletions(-) (limited to 'src/gui.zig') 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- 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- 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 => {}, }; } -- cgit v1.3