summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
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.