diff options
Diffstat (limited to 'src')
| -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 |
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. |
