summaryrefslogtreecommitdiff
path: root/src
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 /src
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.
Diffstat (limited to 'src')
-rw-r--r--src/gui.zig122
-rw-r--r--src/lsp.zig141
-rw-r--r--src/pardes.zig198
-rw-r--r--src/tty.zig118
4 files changed, 561 insertions, 18 deletions
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.