//! Turning the core's `lsp` effect into work on a thread, and its rows back //! into something a loop can deliver. Native-shell side, like host_io.zig and //! shell_bin.zig, and here for the reason those are: all three native shells //! need it and none of them needs a different one. //! //! It was two copies before it was this. tty.zig had it inline and gui.zig had //! it again with `tty.zig's LspJob, and copied for the same reason` written //! over the top — identical down to the comment about a pane with no file. The //! AppKit shell had NEITHER, so its vtable left `pull_lsp` null, the core //! answered its own requests with no rows, and every language query did nothing //! at all in the shell most people run. None of that looked like a missing //! feature from the outside: `gd` just moved no cursor. A third copy is what //! this module exists instead of. //! //! What is genuinely per-host stays per-host, and it is small: which allocator, //! how a finished job reaches the loop (a mutex queue, a vaxis event, an inbox //! plus a wakeup), and what bounds the in-flight set (a refcount to join at //! teardown, or one future to cancel). What is NOT per-host is everything //! below: the core goes on editing the moment the effect is drained, so every //! byte the backend may read has to be COPIED first, and getting that ladder //! subtly different in three places is how one shell reads freed text one //! keystroke later. const std = @import("std"); const pardes = @import("pardes.zig"); const host_api = @import("host.zig"); /// One language query, owned by the worker that runs it. pub const Job = struct { id: u32, kind: pardes.lsp.Kind, offset: u32, path: []u8, source: [:0]u8, arg: []u8, root: []u8, pub fn free(job: *Job, gpa: std.mem.Allocator) void { gpa.free(job.path); gpa.free(job.source); gpa.free(job.arg); gpa.free(job.root); gpa.destroy(job); } }; /// Copy the query out of the core. Null when the pane is gone or an allocation /// failed, and nothing leaks on either path — the ladder frees exactly what it /// had managed to take. /// /// A pane with no file still asks `status`: that query is about the BACKEND, /// not the buffer. Empty path and source then, and the root comes off the /// pane's cwd so a bare terminal still reports which servers it would reach. pub fn snapshot(gpa: std.mem.Allocator, core: *const pardes.Pardes, req: host_api.LspRequest) ?*Job { const pane = core.panes[req.pane] orelse return null; const file = pane.file; const job = gpa.create(Job) catch return null; job.* = .{ .id = req.id, .kind = req.kind, .offset = req.offset, .path = gpa.dupe(u8, if (file) |f| f.path else "") catch { gpa.destroy(job); return null; }, .source = gpa.dupeZ(u8, if (file) |f| f.content else "") catch { gpa.free(job.path); gpa.destroy(job); return null; }, .arg = gpa.dupe(u8, req.arg) catch { gpa.free(job.path); gpa.free(job.source); gpa.destroy(job); return null; }, .root = gpa.dupe(u8, if (file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice()) catch { gpa.free(job.path); gpa.free(job.source); gpa.free(job.arg); gpa.destroy(job); return null; }, }; return job; } /// What a host does with finished rows. It TAKES OWNERSHIP of `rows`, which /// were allocated with the same allocator the job was. pub const Deliver = *const fn (ctx: ?*anyopaque, id: u32, rows: []u8) void; /// Run `job` to completion and hand its rows to `deliver`. Consumes the job /// either way. /// /// This is the whole async execution model, and it is the one every shell /// already uses for its pty reader: do the slow thing off the loop, hand the /// result over as an event, let the core stay a state machine that never /// blocks. The shell owns the result buffer; the backend only writes into it. pub fn work(gpa: std.mem.Allocator, job: *Job, ctx: ?*anyopaque, deliver: Deliver) void { defer job.free(gpa); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); 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.writer); // Duped out of the writer: `deliver` outlives this frame and the writer // does not. A failed dupe drops the answer, which the core survives — the // request times out into no rows, exactly as an empty answer would. const rows = gpa.dupe(u8, out.written()) catch return; deliver(ctx, job.id, rows); } test "a snapshot owns every byte the backend will read" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); var needle: [6]u8 = "needle".*; const job = snapshot(gpa, core, .{ .id = 7, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = &needle, }) orelse return error.SnapshotFailed; defer job.free(gpa); try std.testing.expectEqual(@as(u32, 7), job.id); try std.testing.expectEqual(pardes.lsp.Kind.status, job.kind); // `arg` is the caller's buffer on the way in and the job's own bytes on the // way out. THIS is the property the whole ladder exists for: the core reuses // that buffer for the next builtin's argument the moment the effect drains. try std.testing.expectEqualStrings("needle", job.arg); try std.testing.expect(job.arg.ptr != &needle); // A terminal pane has no file and the query still has to be answerable: // empty path, a NUL-terminated empty source, and the pane's own cwd as the // root so a bare terminal still reports which servers it would reach. The // cwd may legitimately be empty in a core that has never spawned a shell; // what matters is that the job OWNS it rather than borrowing it. try std.testing.expectEqualStrings("", job.path); try std.testing.expectEqual(@as(usize, 0), job.source.len); try std.testing.expectEqual(@as(u8, 0), job.source[0]); const pane = core.panes[core.active].?; try std.testing.expectEqualStrings(pane.cwdSlice(), job.root); if (job.root.len > 0) try std.testing.expect(job.root.ptr != pane.cwdSlice().ptr); } test "a pane that is gone yields no job rather than a null deref" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); // The effect is drained after the core has moved on, so the pane it names // may already have been deleted. Every shell open-coded this check. const empty = for (core.panes, 0..) |slot, id| { if (slot == null) break @as(u8, @intCast(id)); } else return error.NoEmptyPane; try std.testing.expect(snapshot(gpa, core, .{ .id = 1, .kind = .definition, .pane = empty, .offset = 0, .arg = "", }) == null); } test "work consumes the job and hands its rows to the sink" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const Sink = struct { var seen_id: u32 = 0; var seen_rows: ?[]u8 = null; fn take(_: ?*anyopaque, id: u32, rows: []u8) void { seen_id = id; seen_rows = rows; } }; Sink.seen_id = 0; Sink.seen_rows = null; const job = snapshot(gpa, core, .{ .id = 42, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "", }) orelse return error.SnapshotFailed; work(gpa, job, null, Sink.take); // `status` is the one kind that answers with no file and no cursor, which // is what makes it assertable here without a language server on the box. try std.testing.expectEqual(@as(u32, 42), Sink.seen_id); const rows = Sink.seen_rows orelse return error.SinkNeverCalled; defer gpa.free(rows); }