diff options
Diffstat (limited to 'src/lsp_host.zig')
| -rw-r--r-- | src/lsp_host.zig | 206 |
1 files changed, 206 insertions, 0 deletions
diff --git a/src/lsp_host.zig b/src/lsp_host.zig new file mode 100644 index 00000000..803beb31 --- /dev/null +++ b/src/lsp_host.zig @@ -0,0 +1,206 @@ +//! 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); +} |
