diff options
Diffstat (limited to 'src/host.zig')
| -rw-r--r-- | src/host.zig | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/src/host.zig b/src/host.zig new file mode 100644 index 00000000..d3e93d36 --- /dev/null +++ b/src/host.zig @@ -0,0 +1,289 @@ +//! THE HOST SEAM: everything the core cannot do itself, as one struct of +//! OPTIONAL function pointers — `std.mem.Allocator`/`std.Io` shape, and the +//! generalization of two vtables this codebase already grew on its own +//! (`pardes.TtyQuery`, and the macOS shell's `Runtime`). +//! +//! Every method is optional, and a null method is not an error: the core +//! substitutes a default backed by ordinary data structures in this process +//! (`Fallback` below). So a host implements only what it actually has, and the +//! core cannot tell the difference — a `Save` lands in a real file under the +//! tty host and in `Fallback.files` under a host that never wrote a filesystem +//! method, and every path above that behaves identically. +//! +//! Two consequences worth having on purpose: +//! * The zero-method host IS the test harness. A `Host{}` is a complete, +//! deterministic, in-process pardes with a virtual filesystem, a virtual +//! clipboard and silent ptys. +//! * `Fallback` lives on the Pardes instance, not here, so N cores driven by +//! one fan-out host each keep their own state and can run in parallel. +//! +//! WHAT IS NOT HERE, and why: whether a capability EXISTS in this build stays +//! comptime and stays next to the code it shapes (`pardes.platform`, +//! `pardes.pdf_enabled`, `builtins.capabilities`, `PdfSlot`/`HapticSlot`). +//! A vtable cannot make a field zero-sized or a builtin absent from an enum. +//! The rule is: comptime decides what a BUILD has, this vtable decides who +//! SERVES it at runtime. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const source_manifest = @import("source_manifest.zig"); + +pub const LspRequest = struct { + id: u32, + kind: pardes.lsp.Kind, + pane: u8, + offset: u32, + arg: []const u8, +}; + +pub const Host = struct { + ctx: ?*anyopaque = null, + vtable: *const VTable = &.{}, + + /// One optional method per thing a host can do. Adding a method here is + /// additive for every existing host: they keep it null and get the default. + /// + /// EVERY name says how a fan-out must route it, and the compiler enforces + /// that it does (see Fanout.isPull): + /// `push_` every wrapped host gets it, and it returns nothing — a push + /// with an answer would have N answers and no way to pick one. + /// `pull_` exactly ONE host serves it, because there is one of whatever + /// comes back: one value, one sleep that ends, one `Event.paste` + /// for one Ctrl-V, one `lsp_resp` per request id. + pub const VTable = struct { + // ---- the loop's own three seams ---- + /// Block until there is input or `timeout_ms` elapses, translating + /// whatever arrives into `Pardes.update`/`postEvent` calls. This is the + /// ONLY place the process is allowed to sleep: the core never spins. + /// A pull because one host does the sleeping — fanned out, the second + /// host would not be serviced until the first happened to wake. + pull_wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null, + push_present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, + /// After the frame is on screen (panel-presentation acknowledgement, + /// pointer refresh); split from `push_present` because it must observe + /// a frame the user has actually seen. + push_post_present: ?*const fn (ctx: ?*anyopaque) void = null, + /// Per-frame host bookkeeping with no event of its own: cwd polling, a + /// capability handshake landing, gamepad state. + push_poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, + + // ---- pseudo-terminals ---- + push_spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, + push_pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, + push_pty_resize: ?*const fn (ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void = null, + /// Is this pane's terminal still the prompt the host forked, or has a + /// program (vim, a pager, an agent) taken its tty? An effect cannot + /// answer it — the `execute` that asks must choose a destination inside + /// its own update, and effects drain after. A pushed fact would mean + /// every host probing every pane's processes every frame to answer a + /// question asked when a human middle-clicks a word. So the host leaves + /// a way to be asked and the core asks where it decides. The answer + /// must not re-enter the core. + pull_tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null, + + // ---- the filesystem ---- + /// `pane` travels with the bytes only so a host that posts a "saved" + /// message row can name the right pane; the core already resolved the + /// path and the content, so save_file and save_text both land here. + push_write_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void = null, + /// The session dump. Separate because the host also chooses WHERE it + /// goes (dump.outPath is libc-bound; the freestanding core cannot). + push_write_dump: ?*const fn (ctx: ?*anyopaque, bytes: []const u8) void = null, + push_watch_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void = null, + push_watch_theme: ?*const fn (ctx: ?*anyopaque, generation: u32, on: bool) void = null, + push_dump_themes: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null, + + // ---- the desktop ---- + push_set_clipboard: ?*const fn (ctx: ?*anyopaque, text: []const u8) void = null, + /// Ask; the answer arrives later as an ordinary `Event.paste`, which is + /// why this returns nothing and is still a pull: two hosts answering + /// would paste the clipboard twice. Null answers immediately from the + /// in-process clipboard instead, so a request never goes unanswered. + pull_read_clipboard: ?*const fn (ctx: ?*anyopaque) void = null, + push_open_link: ?*const fn (ctx: ?*anyopaque, url: []const u8) void = null, + + // ---- work that must leave the loop ---- + /// Both answer exactly once, keyed by the id they carry, so both are + /// pulls: a second host's reply would arrive for a request already + /// completed and the core would apply it to whatever holds that id now. + pull_lsp: ?*const fn (ctx: ?*anyopaque, req: LspRequest) void = null, + pull_pipe: ?*const fn (ctx: ?*anyopaque, id: u32) void = null, + }; +}; + +/// Where a host with no `write_dump` puts a session dump. Named here so the +/// core writes it and reports it as one path. +pub const fallback_dump_path = "pardes.dump.zon"; + +/// The in-process implementations behind every null method: a virtual +/// filesystem, a virtual clipboard, and a record of what was asked of ptys and +/// the desktop. Ordinary data structures, one set per Pardes instance. +/// +/// The filesystem is not empty. It is pardes's own source, embedded — see +/// source_manifest.zig — with `files` holding only what this session WROTE, so +/// a Save shadows the built-in copy and reading it back returns the edit. That +/// is what makes a host with no file methods a usable pardes rather than one +/// staring at an empty buffer. +/// +/// Only `files` grows, and it grows by REPLACING a path's content, so no +/// session accumulates. A pane whose child does not exist is SILENT: its bytes +/// are dropped rather than transcribed, because nothing reads a transcript back +/// and a browser session would then carry every keystroke forever. +pub const Fallback = struct { + gpa: std.mem.Allocator, + files: std.StringHashMapUnmanaged([]u8) = .empty, + clipboard: std.ArrayListUnmanaged(u8) = .empty, + /// Last link a host with no browser was asked to open. + link: std.ArrayListUnmanaged(u8) = .empty, + spawned: [pardes.MAX_PANES]bool = @splat(false), + watched: [pardes.MAX_PANES]bool = @splat(false), + + pub fn deinit(f: *Fallback) void { + var it = f.files.iterator(); + while (it.next()) |e| { + f.gpa.free(e.key_ptr.*); + f.gpa.free(e.value_ptr.*); + } + f.files.deinit(f.gpa); + f.clipboard.deinit(f.gpa); + f.link.deinit(f.gpa); + } + + pub fn writeFile(f: *Fallback, path: []const u8, bytes: []const u8) void { + const copy = f.gpa.dupe(u8, bytes) catch return; + if (f.files.getEntry(path)) |e| { + f.gpa.free(e.value_ptr.*); + e.value_ptr.* = copy; + return; + } + const key = f.gpa.dupe(u8, path) catch { + f.gpa.free(copy); + return; + }; + f.files.put(f.gpa, key, copy) catch { + f.gpa.free(key); + f.gpa.free(copy); + }; + } + + /// What this path holds now: the session's own write, else the embedded + /// source. Borrowed — the bytes live in the map or in the binary. + pub fn get(f: *const Fallback, path: []const u8) ?[]const u8 { + if (f.files.get(path)) |written| return written; + return source_manifest.find(path); + } + + pub fn setClipboard(f: *Fallback, text: []const u8) void { + f.clipboard.clearRetainingCapacity(); + f.clipboard.appendSlice(f.gpa, text) catch {}; + } + + pub fn setLink(f: *Fallback, url: []const u8) void { + f.link.clearRetainingCapacity(); + f.link.appendSlice(f.gpa, url) catch {}; + } +}; + +/// Fan out one core's host calls to several real hosts at once — the debugging +/// arrangement: every input reaches every host, and each host answers into its +/// own state. +/// +/// It advertises a method only when some wrapped host actually implements it, +/// so wrapping does NOT mask the core's per-method fallback: fan out two hosts +/// that never opened a link and the link still lands in `Fallback`. +pub const Fanout = struct { + hosts: []const Host, + vt: Host.VTable = .{}, + + pub fn init(hosts: []const Host) Fanout { + var f: Fanout = .{ .hosts = hosts }; + inline for (@typeInfo(Host.VTable).@"struct".fields) |field| { + for (hosts) |h| if (@field(h.vtable, field.name) != null) { + @field(f.vt, field.name) = @field(all, field.name); + break; + }; + } + return f; + } + + pub fn host(f: *const Fanout) Host { + return .{ .ctx = @ptrCast(@constCast(f)), .vtable = &f.vt }; + } + + fn self(ctx: ?*anyopaque) *const Fanout { + return @ptrCast(@alignCast(ctx.?)); + } + + /// A wrapper for every method, whether or not this fan-out advertises it. + /// Synthesized, so adding a method to `Host.VTable` needs no code here. + const all: Host.VTable = blk: { + var t: Host.VTable = .{}; + for (@typeInfo(Host.VTable).@"struct".fields) |field| { + @field(t, field.name) = fan(field.name); + } + break :blk t; + }; + + fn Method(comptime name: []const u8) std.builtin.Type.Fn { + const ptr = @typeInfo(@FieldType(Host.VTable, name)).optional.child; + return @typeInfo(@typeInfo(ptr).pointer.child).@"fn"; + } + + /// How to route a method, read off its own name. A method that is neither + /// is a COMPILE ERROR rather than a silent push, because the failure of a + /// forgotten pull is invisible in every unit test and obvious only to the + /// user: one Ctrl-V pasting twice. + fn isPull(comptime name: []const u8) bool { + if (std.mem.startsWith(u8, name, "pull_")) return true; + if (std.mem.startsWith(u8, name, "push_")) { + if (Method(name).return_type.? != void) @compileError("Host.VTable." ++ + name ++ " reaches every host, so it cannot return a value: whose answer would it be?"); + return false; + } + @compileError("Host.VTable." ++ name ++ " must be named push_… (every host gets it) " ++ + "or pull_… (exactly one host serves it, because there is one of whatever comes back)"); + } + + /// The walk, written once: `args` is everything after `ctx`. + fn dispatch(comptime name: []const u8, ctx: ?*anyopaque, args: anytype) Method(name).return_type.? { + for (self(ctx).hosts) |h| if (@field(h.vtable, name)) |fp| { + const answer = @call(.auto, fp, .{h.ctx} ++ args); + if (comptime isPull(name)) return answer; + }; + // `init` installs a wrapper only when some host has the method, so a + // pull always found one; a zero is the honest answer if that changes. + const R = Method(name).return_type.?; + if (comptime R != void) return std.mem.zeroes(R); + } + + /// One wrapper, built from the method's own signature: the parameter list + /// is the only part that cannot be derived, so there is one shape per + /// arity rather than one per method. + fn fan(comptime name: []const u8) @FieldType(Host.VTable, name) { + const m = Method(name); + const R = m.return_type.?; + const P = m.params; + return switch (P.len) { + 1 => struct { + fn w(c: ?*anyopaque) R { + return dispatch(name, c, .{}); + } + }.w, + 2 => struct { + fn w(c: ?*anyopaque, a: P[1].type.?) R { + return dispatch(name, c, .{a}); + } + }.w, + 3 => struct { + fn w(c: ?*anyopaque, a: P[1].type.?, b: P[2].type.?) R { + return dispatch(name, c, .{ a, b }); + } + }.w, + 4 => struct { + fn w(c: ?*anyopaque, a: P[1].type.?, b: P[2].type.?, d: P[3].type.?) R { + return dispatch(name, c, .{ a, b, d }); + } + }.w, + else => @compileError("Fanout has no wrapper shape for " ++ name ++ "'s arity"), + }; + } +}; |
