summaryrefslogtreecommitdiff
path: root/src/host.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/host.zig')
-rw-r--r--src/host.zig289
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"),
+ };
+ }
+};