diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /src/acmefs.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/acmefs.zig')
| -rw-r--r-- | src/acmefs.zig | 3782 |
1 files changed, 0 insertions, 3782 deletions
diff --git a/src/acmefs.zig b/src/acmefs.zig deleted file mode 100644 index dc2780fe..00000000 --- a/src/acmefs.zig +++ /dev/null @@ -1,3782 +0,0 @@ -//! ACME'S CONTROL FILESYSTEM, as a pure transaction over the core. -//! -//! plan9's acme serves `/mnt/acme`: a directory per window holding `addr`, -//! `body`, `ctl`, `data`, `event`, `tag`..., and a program that opens those -//! files IS an editor extension — no plugin API, no embedded interpreter, no -//! rebuild. `pardes --fs` serves the same tree over Linux FUSE (src/fuse.zig), -//! and this file is the whole of what the files MEAN. Read it beside acme's -//! `fsys.c` (the tree) and `xfid.c` (the handlers). -//! -//! THE SHAPE, and why it is this shape. A filesystem is a request/response -//! protocol driven by other processes, i.e. exactly the kind of concurrency -//! the core does not have and must not grow. acme answers it with a thread per -//! in-flight request (`xfidallocthread`, a `Channel` per `Xfid`, a `QLock` per -//! window); pardes cannot and should not, so: -//! -//! * This module is a PURE MAIN-THREAD TRANSACTION: `handle(p, req) Reply`. -//! No thread, no waiting, no callback, no allocation on the hot path. It -//! is freestanding-safe (no libc, no OS) and unit-testable with no FUSE -//! anywhere near it — the tests below post requests and read replies. -//! * Requests arrive as an ordinary `Event.fs_req` and answers leave as an -//! ordinary `Effect.fs_reply`, so the transport is the queue every other -//! host<->core message already uses. A backend with no threads at all -//! (the browser, a test) is not a special case: it either never sends a -//! request, or sends one from its own frame loop. -//! * BLOCKING — acme's `event` file, whose read waits for the user to do -//! something (acme parks the `Xfid` in `w->eventx` and a later `winevent` -//! sends it a message) — is `Status.again` here: "nothing consumed, ask me -//! again". The waiting lives in the host, which is where the kernel's -//! request already is. The core keeps no waiter list and no wakeups. -//! -//! DIVERGENCE FROM ACME, deliberate: acme counts RUNES, pardes counts BYTES -//! (clamped to grapheme boundaries). Every offset in this filesystem — `addr`, -//! `data`, the event records' q0/q1, `index`'s lengths — is a byte offset, -//! because pardes is byte-addressed end to end (selections, look spots, LSP -//! offsets) and a second coordinate system would mean an O(n) conversion at -//! every boundary and a lossy `addr=dot`. acme pays that cost the other way -//! round: it keeps the document as `Rune*` and converts on every utf read -//! (`xfidutfread`, which carries a "BUG: stupid code: scan from beginning" -//! comment for its cache miss). Identical for ASCII, which is what scripts -//! compute with. -const std = @import("std"); -const mvzr = @import("mvzr"); -const pardes = @import("pardes.zig"); -const config = @import("config.zig"); -const modal = @import("modal.zig"); -const file_pane = @import("file_pane.zig"); -const output_pane = @import("output_pane.zig"); -/// only for the scrollback read below: a terminal's `body` is a grid, and -/// term_pane owns how a grid becomes bytes (and whether there is one at all). -const term_pane = @import("term_pane.zig"); -/// only for `look.readFile`, which is what a `get` verb IS — the same -/// synchronous path-backed read `file_pane.open` does, and the one place this -/// module touches a disk. -const look = @import("look.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const MAX_PANES = pardes.MAX_PANES; - -// ============================================================================ -// THE ABI — what a transport hands in and gets back. -// ============================================================================ - -/// The filesystem operations the core answers. Protocol-neutral on purpose: -/// FUSE opcodes, 9P messages and a unit test all reduce to these. -pub const Op = enum(u8) { - /// resolve `data` (a name) inside the directory `node` - lookup, - getattr, - /// only `truncate` is honoured; a filesystem of live editor state has no - /// mode, owner or timestamps to set - setattr, - open, - read, - write, - release, - readdir, - statfs, -}; - -pub const Status = enum(u8) { - ok, - /// NO DATA YET, nothing consumed: the transport must hold this request and - /// re-submit it unchanged on a later frame. The one blocking primitive, - /// and the reason the core needs no waiters (see the header). - again, - err, -}; - -/// One operation. `data` is BORROWED for the length of the single -/// `update(.{ .fs_req = ... })` call that carries it — the same rule as -/// `.pty_read`'s bytes — so this never goes through `postEvent`. -pub const Req = struct { - /// opaque echo token; the transport's request id (FUSE `unique`) - tag: u64, - op: Op, - node: u64, - /// from `.open`, on read/write/release - handle: u32 = 0, - /// read/write: byte offset. readdir: how many entries to skip. - off: u64 = 0, - /// read/readdir: bytes wanted. Writes carry their length in `data`. - size: u32 = 0, - /// lookup: the name. write: the bytes. - data: []const u8 = &.{}, - /// setattr: a size was set (only 0 means anything here) - truncate: bool = false, -}; - -pub const Reply = struct { - tag: u64, - status: Status = .ok, - /// positive errno when `status == .err` - errno: u16 = 0, - /// lookup/getattr/setattr answer this; `open` leaves it zeroed - attr: Attr = .{}, - /// `open` answers this; every later read/write/release repeats it - handle: u32 = 0, - payload: Payload = .none, - /// write: how many of the offered bytes were taken. A short count is a - /// real answer (`data` refusing a partial grapheme), not an error. - written: u32 = 0, - - pub const Attr = struct { - node: u64 = 0, - dir: bool = false, - size: u64 = 0, - /// permission bits only; the transport adds the format bits - mode: u16 = 0o600, - }; - - /// WHERE THE ANSWER'S BYTES ARE. Resolved by `pardes.fsPayload` inside the - /// effect drain — a borrow window identical to `.save_text`'s — so reading - /// a megabyte of body copies nothing. - pub const Payload = union(enum) { - none, - /// `State.out[0..len]`: formatted answers (ctl, addr, index, dirents, - /// event records). Valid until the next `handle` call. - staged: u32, - /// a slice of a live pane's text. `serial` rejects a reused slot - /// exactly like `.save_text` does. - region: struct { pane: u8, serial: u32, off: u32, len: u32 }, - }; - - pub fn fail(tag: u64, e: u16) Reply { - return .{ .tag = tag, .status = .err, .errno = e }; - } -}; - -/// The errno values this filesystem returns, standing in for acme's error -/// strings (`fsys.c`/`xfid.c`: Eperm, Ebadctl, Ebadaddr, Ebadevent, Edel...). -/// A filesystem has one channel for "no": the number. -pub const E = struct { - pub const PERM: u16 = 1; - pub const NOENT: u16 = 2; - pub const IO: u16 = 5; - pub const NOMEM: u16 = 12; - pub const NOTDIR: u16 = 20; - pub const INVAL: u16 = 22; - pub const NFILE: u16 = 23; - /// the one FILE here with a fixed capacity: a pane's editable tag tail is - /// a bounded one-line buffer, so a write with no room left is full rather - /// than refused (`writeTag`) - pub const NOSPC: u16 = 28; - pub const NOSYS: u16 = 38; -}; - -// ============================================================================ -// THE TREE — nodes, names, and the packing that makes both cheap. -// ============================================================================ - -/// One file inside a pane's directory: acme's `dirtabw` minus the plan9 -/// compatibility stubs (`editout` needs acme's Edit language; `draw`, -/// `consctl` and `label` are rio artefacts acme keeps for other programs' -/// sake), plus the `pty/` directory and its three files — the one thing here -/// with no prior art anywhere, because acme has no terminals and `ad` has no -/// terminal surface at all. -/// -/// A pty is a file interface wearing the wrong clothes: everything one wants -/// to do to it is an `ioctl`, and no dialect of this protocol has one. So -/// `TIOCSWINSZ` becomes `winsize 80 24`, `kill` becomes `sig INT`, spawn -/// becomes `exec`, and `TIOCGWINSZ` becomes a read of `status`. Three files -/// and no more: being first is a reason to keep it small. -/// -/// THE FIELD IS FULL AFTER THIS. `Node.file` is a u4 — sixteen values — and -/// these four take it to fifteen used. ONE VALUE (15) IS LEFT. The next file -/// added to a pane's directory needs a wider field, which means `Node`'s -/// packing changes and every node id in flight through a transport changes -/// with it; that is a deliberate wall, not an oversight, and it is why `pty/` -/// is a DIRECTORY holding three names rather than three more names beside -/// `body` — a subdirectory costs one value for the directory itself and buys -/// a namespace of its own, so `ctl` and `data` did not have to be renamed. -pub const PaneFile = enum(u4) { - dir = 0, - addr, - body, - ctl, - data, - errors, - event, - tag, - xdata, - rdsel, - wrsel, - /// the `pty/` directory itself, present only on a terminal pane - pty, - /// `pty/ctl`: `winsize`, `sig`, `exec`. Spelled with the prefix because - /// the enum is flat — the tree is two levels and the tag namespace is one - /// — and `name()` below is what puts the short name back on the wire. - pty_ctl, - /// `pty/status`: the dimensions and who holds the tty - pty_status, - /// `pty/data`: the raw stream, both directions - pty_data, - - /// Every name IS the variant's name, except the directory itself (`.` is - /// not an identifier) and the three inside `pty/`, whose names are already - /// taken by files beside `body` and so carry a prefix in the enum only. - pub fn name(f: PaneFile) []const u8 { - return switch (f) { - .dir => ".", - .pty_ctl => "ctl", - .pty_status => "status", - .pty_data => "data", - else => @tagName(f), - }; - } - - /// acme's dirtabw modes: 0400 read, 0200 write, 0600 both. - pub fn mode(f: PaneFile) u16 { - return switch (f) { - .dir, .pty => 0o500, - .errors, .wrsel, .pty_ctl => 0o200, - .rdsel, .pty_status => 0o400, - else => 0o600, - }; - } - - /// The two directories a pane has. Asked by `stat` and by every handler - /// that must refuse to treat a directory as a file. - pub fn isDir(f: PaneFile) bool { - return f == .dir or f == .pty; - } - - /// Does this name exist ONLY on a terminal pane? A file pane has no pty, - /// so the whole subtree is absent there rather than present and refusing: - /// a script tests `-d $PARDES_FS/7/pty` to find out whether pane 7 is a - /// terminal, which is a question the tree could not answer before. - pub fn inPty(f: PaneFile) bool { - return switch (f) { - .pty, .pty_ctl, .pty_status, .pty_data => true, - else => false, - }; - } -}; - -/// The files at the root, and the root itself. `new` is a directory whose -/// every lookup CREATES a pane (acme(4): "Accessing any file in new creates a -/// new window"), which is how a script opens one without a keystroke. -pub const TopFile = enum(u4) { - root = 1, - index = 2, - cons = 3, - new = 4, - - pub fn name(f: TopFile) []const u8 { - return if (f == .root) "." else @tagName(f); - } - - pub fn mode(f: TopFile) u16 { - return switch (f) { - .root, .new => 0o500, - .index => 0o400, - .cons => 0o200, - }; - } - - pub fn dir(f: TopFile) bool { - return f == .root or f == .new; - } -}; - -/// A NODE ID, which is one integer to the kernel and two fields to us: the -/// file within a pane's directory, and the pane's SERIAL — never reused, so a -/// node id can never come to mean a different pane. acme does the same packing -/// with `QID(w->id, f)` / `WIN(q)` / `FILE(q)` macros over an int; a packed -/// struct is the same bits with the shifts and masks checked by the compiler, -/// and `serial == 0` (no pane) is what keeps the top-level ids 1..5 out of the -/// way with no separate range check. -pub const Node = packed struct(u64) { - file: u4 = 0, - serial: u60 = 0, - - pub fn of(serial: u32, file: PaneFile) u64 { - std.debug.assert(serial != 0); - return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); - } - - /// What this id points at, or null when it names neither a top-level file - /// nor a possible pane file. Validity is decided HERE so no handler has to. - pub fn target(node: u64) ?Target { - const n: Node = @bitCast(node); - if (n.serial == 0) { - return .{ .top = std.enums.fromInt(TopFile, n.file) orelse return null }; - } - return .{ .pane = .{ - .serial = std.math.cast(u32, n.serial) orelse return null, - .file = std.enums.fromInt(PaneFile, n.file) orelse return null, - } }; - } -}; - -/// What a node id points at. -pub const Target = union(enum) { - top: TopFile, - pane: struct { serial: u32, file: PaneFile }, -}; - -// ============================================================================ -// STATE — everything the filesystem remembers between requests. -// ============================================================================ - -/// What a formatted answer starts out able to hold before it grows: `index` -/// over every pane, a directory listing, one event record. The buffer is kept -/// between requests and cleared, not freed, so the steady state allocates -/// nothing and nothing is capped by a number picked here. -pub const out_reserve = 4 * 1024; - -/// Records a reader has not taken yet, per pane. Beyond this the oldest are -/// dropped: an editor must not stall or grow without bound because a script -/// stopped reading, and a reader that fell this far behind has already lost -/// the thread — it can re-read `body` and resynchronise. (acme grows -/// `w->events` with `realloc` and has no bound at all.) -pub const queue_cap = 64 * 1024; - -/// A byte queue of formatted event records, each framed by its length so a -/// record whose TEXT contains newlines still comes out whole. -pub const Queue = struct { - buf: std.ArrayList(u8) = .empty, - /// How much of `buf` has been consumed. Popping moves this instead of - /// sliding the remainder down: a full queue holds thousands of ~20-byte - /// records, and a memmove per pop made draining one quadratic. The space - /// is reclaimed when the head passes half the buffer, so the amortised - /// cost of a pop is a pointer bump. - head: usize = 0, - - pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void { - q.buf.deinit(gpa); - q.head = 0; - } - - pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void { - if (record.len > std.math.maxInt(u32)) return; - while (q.buf.items.len - q.head + record.len + 4 > queue_cap) { - if (q.peek() == null) return; - q.pop(); - } - q.compact(); - var head: [4]u8 = undefined; - std.mem.writeInt(u32, &head, @intCast(record.len), .little); - q.buf.appendSlice(gpa, &head) catch return; - q.buf.appendSlice(gpa, record) catch { - q.buf.shrinkRetainingCapacity(q.buf.items.len - 4); - return; - }; - } - - /// The oldest record, or null when empty. Does not consume. - pub fn peek(q: *const Queue) ?[]const u8 { - const rest = q.buf.items[@min(q.head, q.buf.items.len)..]; - if (rest.len < 4) return null; - const len = std.mem.readInt(u32, rest[0..4], .little); - if (rest.len < 4 + len) return null; - return rest[4 .. 4 + len]; - } - - pub fn pop(q: *Queue) void { - const record = q.peek() orelse return; - q.head += 4 + record.len; - if (q.head == q.buf.items.len) { - q.buf.clearRetainingCapacity(); - q.head = 0; - } - } - - /// Consume `n` bytes off the FRONT of the oldest record, leaving whatever - /// is left of it as the new oldest record. - /// - /// A record-framed queue can do this at all only because the frame is a - /// length written IMMEDIATELY BEFORE its bytes: shortening the record - /// means writing the new length into the four bytes that now sit just - /// before what remains, and those four bytes are inside the region the old - /// length and the consumed bytes already occupied. Nothing live is - /// overwritten and nothing moves. - /// - /// Only a STREAM wants this. `event`'s records are atomic — half a record - /// is unparseable and desynchronises the reader for the rest of the - /// session — so `event` uses `pop` and refuses a short read. `pty/data` - /// carries raw pty bytes, which have no framing of their own: the records - /// there are only "what arrived in one `.output` event" and a reader may - /// split them anywhere, exactly as `read(2)` on the pty itself would. - pub fn popFront(q: *Queue, n: usize) void { - const record = q.peek() orelse return; - if (n >= record.len) return q.pop(); - q.head += n; - std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little); - } - - fn compact(q: *Queue) void { - if (q.head == 0 or q.head * 2 < q.buf.items.len) return; - const rest = q.buf.items.len - q.head; - std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]); - q.buf.shrinkRetainingCapacity(rest); - q.head = 0; - } - - pub fn empty(q: *const Queue) bool { - return q.peek() == null; - } - - /// Drop everything AND give the memory back. A queue whose last reader - /// left must not hold `queue_cap` of a program's output until its pane - /// dies; `clearRetainingCapacity` inside `pop` is the right thing between - /// reads and the wrong thing between readers. - pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { - q.buf.clearAndFree(gpa); - q.head = 0; - } -}; - -/// Per-pane filesystem state, indexed by pane SLOT (not serial): it dies with -/// the pane, and a reused slot must start clean. -pub const PaneFs = struct { - /// acme's `w->addr`: where `data`/`xdata` read and write. Byte offsets. - addr: Range = .{}, - /// `limit=addr`: the range regex searches are confined to, or none. - limit: ?Range = null, - /// how many opens of this pane's `event` file are live. Non-zero means the - /// pane is SCRIPT-DRIVEN: its Look and Exec are reported, not performed. - readers: u16 = 0, - events: Queue = .{}, - /// `nomark`: writes stop pushing an undo point each, so a script's batch - /// of edits is one Undo (acme: `w->nomark`). - nomark: bool = false, - /// `noscroll`: a body write does not drag the view to the new text. - noscroll: bool = false, - /// The tag as it was at the end of the last update, so tag edits can be - /// reported without a hook in every tag mutation. Only kept while somebody - /// is listening. - tag_snap: std.ArrayList(u8) = .empty, - /// How many opens of this pane's `pty/data` file are live. THE GATE on the - /// raw queue below, and deliberately NOT `readers` above: a script reading - /// a terminal's output stream is not claiming the pane's buttons, so a pty - /// reader must not make the pane script-driven. Nobody reading means - /// `notePtyOutput` is one load and one branch and copies nothing. - pty_readers: u16 = 0, - /// Raw pty bytes on their way to the emulator, kept only while somebody is - /// reading them. The core does not buffer these anywhere else — they go - /// into the grid, and a grid cannot be un-rendered back into a byte - /// stream — so this is where `pty/data`'s read comes from. Same - /// drop-oldest cap as `events`, for the same reason: a script that stops - /// reading must not grow the editor. - pty_out: Queue = .{}, - - pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; - - fn deinit(pf: *PaneFs, gpa: std.mem.Allocator) void { - pf.events.deinit(gpa); - pf.pty_out.deinit(gpa); - pf.tag_snap.deinit(gpa); - pf.* = .{}; - } -}; - -/// The core's filesystem state. Lives on `Pardes`; zero-initialised, so a core -/// that never serves a filesystem pays one branch per frame and no memory -/// beyond this struct. -pub const State = struct { - /// Formatted answers, valid until the next `handle` call (Payload.staged). - /// Kept and cleared rather than freed: after the first few requests the - /// capacity is there and staging an answer allocates nothing. - out: std.ArrayList(u8) = .empty, - panes: [MAX_PANES]PaneFs = @splat(.{}), - /// How many `event` files are open anywhere. The one gate every recording - /// hook in the core is behind: nobody listening, nothing recorded, no diff - /// computed, no bytes copied. - listeners: u16 = 0, - /// Which input the core is handling, as acme's origin character: `K` - /// keyboard, `M` mouse, `E` a write to body/tag through this filesystem, - /// `F` an action through one of its other files. Set once per update. - origin: u8 = 'K', - - pub fn deinit(st: *State, gpa: std.mem.Allocator) void { - for (&st.panes) |*pf| pf.deinit(gpa); - st.out.deinit(gpa); - } - - /// Start a fresh answer. The previous one's bytes are dead the moment the - /// next request arrives, which is exactly the borrow window `fsPayload` - /// documents. - pub fn stage(st: *State, gpa: std.mem.Allocator) *std.ArrayList(u8) { - st.out.clearRetainingCapacity(); - st.out.ensureTotalCapacity(gpa, out_reserve) catch {}; - return &st.out; - } - - /// A pane died: drop its filesystem state, and with it any listener count - /// it held, so a script killed with its pane cannot leave the editor - /// suppressing button actions forever. - pub fn forget(st: *State, gpa: std.mem.Allocator, id: usize) void { - if (id >= MAX_PANES) return; - st.listeners -= @min(st.listeners, st.panes[id].readers); - st.panes[id].deinit(gpa); - } - - /// Is anybody reading this pane's events? The suppression rule and every - /// recording hook ask this. - pub fn scripted(st: *const State, id: usize) bool { - return id < MAX_PANES and st.panes[id].readers != 0; - } -}; - -// ============================================================================ -// EVENT RECORDS — what the core reports, in acme's wire format. -// ============================================================================ - -/// acme's record, byte for byte: origin char, type char, then four -/// blank-separated decimals (q0, q1, flag, text length), a blank, the text, -/// and a newline — `winevent`'s `"%c%d %d %d %d %.*S\n"` with the owner char -/// pushed in front (`wind.c`). Text of 256 bytes or more is elided (the -/// reader fetches it from `data`), which is also what bounds this buffer. -pub const max_record_text = 256; - -/// The action characters. Lower case is the tag, upper case the body, which is -/// how a reader tells them apart with no extra field. -pub const Action = enum(u8) { - body_delete = 'D', - tag_delete = 'd', - body_insert = 'I', - tag_insert = 'i', - body_look = 'L', - tag_look = 'l', - body_exec = 'X', - tag_exec = 'x', - - /// The enum IS the character, the way acme's `winevent` takes a `char` and - /// prints `%c` (`wind.c`) — so these three are expressions rather than the - /// three parallel switches that spelled the same alphabet out again. - pub fn char(a: Action) u8 { - return @intFromEnum(a); - } - - pub fn fromChar(c: u8) ?Action { - return std.enums.fromInt(Action, c); - } - - /// Lower case is the tag, upper case the body: acme's whole encoding of - /// WHICH TEXT a record is about, with no extra field. - pub fn onTag(a: Action) bool { - return @intFromEnum(a) >= 'a'; - } -}; - -/// Flag bits, acme(4). Look and exec are different vocabularies at the same -/// bit positions, so they get separate names rather than one enum. -pub const flag_builtin: u32 = 1; -pub const flag_expansion: u32 = 2; -pub const flag_filename: u32 = 4; -pub const flag_chorded: u32 = 8; - -/// Format one record into `buf` and return the bytes. -pub fn formatRecord( - buf: []u8, - origin: u8, - action: Action, - q0: u32, - q1: u32, - flag: u32, - text: []const u8, -) []const u8 { - const sent = if (text.len >= max_record_text) text[0..0] else text; - return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{ - origin, - action.char(), - q0, - q1, - flag, - sent.len, - sent, - }) catch buf[0..0]; -} - -/// THE SPAN TWO VERSIONS OF A TEXT DIFFER IN: everything outside their common -/// prefix and common suffix. -/// -/// Chunked through `std.mem.eql`, which lowers to vectorised compares. That is -/// not premature: this runs on EVERY edit of a scripted pane, over the whole -/// buffer, and the byte-at-a-time loop it replaces cost 2.4x per keystroke on -/// a 40 KB body (`zig build fs-bench`, the two `keystroke` rows). -pub const Span = struct { at: u32, removed: u32, inserted: u32 }; - -pub fn diffSpan(old: []const u8, new: []const u8) Span { - const both = @min(old.len, new.len); - const stride = 64; - var head: usize = 0; - while (head + stride <= both and - std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride; - while (head < both and old[head] == new[head]) head += 1; - var tail: usize = 0; - const rest = both - head; - while (tail + stride <= rest and std.mem.eql( - u8, - old[old.len - tail - stride ..][0..stride], - new[new.len - tail - stride ..][0..stride], - )) tail += stride; - while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1; - return .{ - .at = @intCast(head), - .removed = @intCast(old.len - tail - head), - .inserted = @intCast(new.len - tail - head), - }; -} - -/// Report a whole-text replacement the way acme reports an edit: the deletion -/// first and then the insertion, because that is the order `textdelete` and -/// `textinsert` would have run in. pardes replaces whole buffers, so the pair -/// is recovered here — one implementation, one place that knows the order, and -/// the only cost paid by an unscripted editor is the `scripted` check. -pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void { - if (!p.fs.scripted(id)) return; - const span = diffSpan(old, new); - if (span.removed == 0 and span.inserted == 0) return; - if (span.removed > 0) _ = noteAction( - p, - id, - if (on_tag) .tag_delete else .body_delete, - span.at, - span.at + span.removed, - 0, - "", - ); - if (span.inserted > 0) _ = noteAction( - p, - id, - if (on_tag) .tag_insert else .body_insert, - span.at, - span.at + span.inserted, - 0, - new[span.at..][0..span.inserted], - ); -} - -/// Record a Look or an Exec, and say whether THE CORE MUST NOT PERFORM IT. -/// -/// That inversion is acme's whole extension model: while a script holds a -/// pane's `event` file open, buttons 2 and 3 in that pane belong to the script -/// — the words in its tag are its commands, not pardes's. A script that dies -/// closes the file and the pane goes back to being an editor. -pub fn noteAction( - p: *Pardes, - id: usize, - action: Action, - q0: u32, - q1: u32, - flag: u32, - text: []const u8, -) bool { - if (!p.fs.scripted(id)) return false; - var buf: [max_record_text + 64]u8 = undefined; - const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text); - p.fs.panes[id].events.push(p.gpa, record); - return true; -} - -/// A PANE'S SHELL PRODUCED OUTPUT, raw, before the emulator ate it. -/// -/// The one hook `pty/data`'s read needs, and the reason it has to be a hook at -/// all: the core's only memory of a program's output is the emulator GRID, -/// which is a rendering — the escape sequences are gone, the scrollback is -/// reflowed, and no amount of reading it back gives a script the byte stream a -/// pipe would have given it. So the bytes are copied here, where they arrive, -/// or not at all. -/// -/// GATED ON A READER COUNT, exactly as every recording hook in this file is -/// gated on `scripted`: a pane nobody is reading pays one load and one branch -/// and allocates nothing, which is what makes an editor that serves this -/// filesystem cost the same as one that does not. `Queue` caps itself and -/// drops the oldest, so a script that opens the file and then stops reading -/// bounds the damage at `queue_cap` per pane. -pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { - if (id >= MAX_PANES or bytes.len == 0) return; - const pf = &p.fs.panes[id]; - if (pf.pty_readers == 0) return; - // SPLIT, because a record larger than `queue_cap - 4` can never be - // admitted: `Queue.push`'s eviction loop pops until `peek()` is null — - // destroying every unread byte the script was still owed — and then drops - // the new record too, silently. - // - // That is not a theoretical size. Every host reads a pty master with a - // 64 KiB buffer (`pty_chunk` in detached/server.zig, `[0x10000]u8` in - // tty.zig, gui.zig and macos.zig) and a single read really does return - // 65536 on Linux — measured. So a pane running a build or a `cat` of - // anything large produces exactly the record that empties the queue, - // repeatedly, for as long as a script holds `pty/data` open. - // - // The `event` queue never met this because its records are a few dozen - // bytes; `pty/data` inherited the cap without inheriting that property. - // Half the cap per record, so a full queue is at least two records and the - // eviction loop always has something to evict. - var off: usize = 0; - while (off < bytes.len) { - const n = @min(bytes.len - off, queue_cap / 2); - pf.pty_out.push(p.gpa, bytes[off..][0..n]); - off += n; - } -} - -// ============================================================================ -// THE TRANSACTION. -// ============================================================================ - -/// Answer one filesystem request against the live editor. The only entry -/// point: `Event.fs_req` lands here and the `Reply` leaves as -/// `Effect.fs_reply`. -pub fn handle(p: *Pardes, req: Req) Reply { - const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); - // THE ORIGIN CHARACTER for everything this request goes on to cause — - // including the TAG DIFF the core takes at the end of the update, after - // this function has returned. acme sets `w->owner` in `winlock` and calls - // `winsettag` before `winunlock`, so the tag change a body write provokes - // (the dirty marker appearing) is attributed to that write and not to - // whatever touched the editor last. Set once, here, for the same reason. - // - // Nothing restores it: `Pardes.update` sets the origin afresh on every - // keystroke and every mouse event, which is what owns it the rest of the - // time. A read cannot cause a record, so only the mutating ops set it. - if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { - // acme's `xfidwrite` opens with exactly this: `c = 'F'; if(qid==QWtag - // || qid==QWbody) c = 'E';` — `E` is "writes to the body or tag file", - // `F` is "actions through the window's other files" (acme(4)). - .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), - .top => 'F', - }; - return switch (req.op) { - .lookup => lookup(p, req, target), - .getattr => switch (attrOf(p, target)) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }, - .setattr => setattr(p, req, target), - .open => open(p, req, target), - .release => release(p, req), - .readdir => readdir(p, req, target), - .read => read(p, req, target), - .write => write(p, req, target), - // A synthetic filesystem has no blocks. Answering successfully with - // zeros keeps `df` and anything that stats the mount working. - .statfs => .{ .tag = req.tag }, - }; -} - -const AttrResult = union(enum) { ok: Reply.Attr, missing }; - -fn attrOf(p: *Pardes, target: Target) AttrResult { - switch (target) { - .top => |f| return .{ .ok = .{ - .node = @intFromEnum(f), - .dir = f.dir(), - .mode = f.mode(), - .size = topSize(p, f), - } }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return .missing; - // THE WHOLE OF "a non-terminal pane has no pty/". Decided here so - // no handler has to: `lookup` answers with the target's attributes - // and `getattr` asks the same question, so one check makes the - // subtree ENOENT on a file pane for every operation at once. - if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing; - return .{ .ok = .{ - .node = Node.of(t.serial, t.file), - .dir = t.file.isDir(), - .mode = t.file.mode(), - .size = paneFileSize(p, id, t.file), - } }; - }, - } -} - -/// A size for `stat`. Exact where it is cheap and honest (`body`, `tag`), zero -/// where the file is a stream whose length is not a property (`event`, `log`); -/// FUSE serves these with direct IO, so a zero-length file still reads. -fn topSize(p: *Pardes, f: TopFile) u64 { - return switch (f) { - .root, .new, .cons => 0, - .index => indexLen(p), - }; -} - -fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 { - const pane = p.panes[id] orelse return 0; - return switch (f) { - .body, .data, .xdata => bodyLen(p, pane), - .tag => tagLen(p, pane), - // `pty/status` is formatted per read like `ctl` is, and `pty/data` is - // a stream whose length is not a property of anything. - .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0, - .pty, .pty_ctl, .pty_status, .pty_data => 0, - }; -} - -// --------------------------------------------------------------------------- -// PER-FILE SEMANTICS. Everything above is the frame: the ABI, the tree, the -// state, the records. Everything below is what acme's xfid.c does. -// --------------------------------------------------------------------------- - -// =========================================================================== -// THE TWO TEXTS A PANE HAS. Every handler below asks these, so "what is this -// pane's body" has one answer here and not eleven answers scattered about. -// =========================================================================== - -/// A pane's BODY, BORROWED. A file pane — which includes every output buffer -/// — lends its content, and that is the whole reason a `body` read costs -/// nothing (`Payload.region`). A terminal has no such buffer: its body is the -/// emulator's scrollback, which has to be RENDERED before it is bytes, so it -/// is not lendable and `readBody` produces one instead. Empty here therefore -/// means "nothing to lend", which for a terminal is not "empty document". -fn bodyOf(pane: *const Pane) []const u8 { - if (pane.file) |*f| return f.content; - return ""; -} - -/// ...and the writable side of the same question. Null is "this pane has no -/// document", which is every terminal and the answer to every write that -/// would need one. -fn fileOf(pane: *Pane) ?*file_pane.State { - return if (pane.file) |*f| f else null; -} - -/// The pane's TAG exactly as it is drawn: the live read-only prefix (the path, -/// the dirty marker, the pane's builtin words, the alignment gap) then the -/// editable tail. -/// -/// Scratch-owned — and `Pardes.update` resets that arena before the transport -/// ever reads a payload, so every tag answer is COPIED into `State.out`. -/// `body` is the only text lent out, because it is the only one that is a -/// buffer rather than a rendering. -fn tagOf(p: *Pardes, pane: *Pane) []const u8 { - return p.tagText(p.scratch.allocator(), pane) catch ""; -} - -/// The directory a pane belongs to: acme's "the directory currently named in -/// the tag", which is where this pane's `+Errors` goes. -fn dirOf(pane: *Pane) []const u8 { - if (pane.file) |*f| return std.fs.path.dirname(f.path) orelse "/"; - const cwd = pane.cwdSlice(); - return if (cwd.len > 0) cwd else "/"; -} - -/// acme's `w->dirty`: the body differs from what is on disk. A terminal and an -/// output buffer have nothing on disk, so they are never dirty — the same -/// `saves` trait the tag's `*` marker already asks. -fn dirtyOf(pane: *const Pane) bool { - const f = if (pane.file) |*x| x else return false; - if (!output_pane.fileTraits(f.output).saves) return false; - return f.revision != f.saved_revision; -} - -/// A byte offset as a `Range` field. A pane holding four gigabytes of text is -/// not something this editor does; saturating is honest where a silent wrap -/// would hand a script an address pointing at the wrong end of the file. -fn clip(n: usize) u32 { - return std.math.cast(u32, n) orelse std.math.maxInt(u32); -} - -fn cellOf(row: i32, col: i32) modal.Cursor { - return .{ .row = @intCast(@max(0, row)), .col = @intCast(@max(0, col)) }; -} - -fn firstLine(s: []const u8) []const u8 { - return s[0 .. std.mem.indexOfScalar(u8, s, '\n') orelse s.len]; -} - -/// acme's DOT — the user's selection — as a byte range over the body. -/// -/// pardes keeps the selection as two (row, col) cells with a HELIX block -/// cursor, i.e. the head cell is INSIDE the range; acme's dot is gap to gap. -/// This is the one place that conversion lives and `setDot` is its inverse, so -/// `addr=dot` followed by `dot=addr` is the identity rather than a range that -/// creeps by one grapheme each round trip. -fn dotOf(pane: *Pane) PaneFs.Range { - const text = bodyOf(pane); - const head = modal.hxOff(text, cellOf(pane.cur_row, pane.cur_col)); - if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; - const anchor = modal.hxOff(text, cellOf(pane.vsel.row, pane.vsel.col)); - var hi = @max(head, anchor); - if (hi < text.len) hi = modal.nextGrapheme(text, hi); - return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; -} - -/// acme's `textsetselect`. The head lands ON the last grapheme of the range, -/// never one past it, because that is where every pardes motion leaves it and -/// a cursor sitting one cell right of its own selection is a selection the -/// acme chords will not act on. -fn setDot(pane: *Pane, r: PaneFs.Range) void { - const text = bodyOf(pane); - const q0 = @min(@as(usize, r.q0), text.len); - const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); - const a = modal.hxPos(text, q0); - pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; - const h = modal.hxPos(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0); - pane.cur_row = @intCast(h.row); - pane.cur_col = @intCast(h.col); - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.msel.active = false; - pane.ensureCursorVisible(); -} - -/// acme's `textshow`: put a spot on screen. Suppressed by `noscroll`. -fn showOffset(pane: *Pane, off: usize) void { - const text = bodyOf(pane); - const c = modal.hxPos(text, @min(off, text.len)); - pane.cur_row = @intCast(c.row); - pane.cur_col = @intCast(c.col); - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.ensureCursorVisible(); -} - -/// acme's `clampaddr`. Its `Range` is signed and it clamps both ends; ours is -/// unsigned, so only the top can be wrong — and it can, the moment a pane's -/// body shrinks under a stored address. -fn clampAddr(pf: *PaneFs, len: usize) void { - const n = clip(len); - pf.addr.q0 = @min(pf.addr.q0, n); - pf.addr.q1 = @min(pf.addr.q1, n); - if (pf.limit) |*l| { - l.q0 = @min(l.q0, n); - l.q1 = @min(l.q1, n); - } -} - -/// Move a range across an edit at `at` that replaced `removed` bytes with -/// `inserted` — acme's `if(tq0 >= q0) tq0 += nr;`, applied to both ends, which -/// is what keeps a script rewriting text under your cursor from dragging the -/// cursor onto a different word. -fn shiftBy(r: PaneFs.Range, at: u32, removed: u32, inserted: u32) PaneFs.Range { - return .{ .q0 = shiftOne(r.q0, at, removed, inserted), .q1 = shiftOne(r.q1, at, removed, inserted) }; -} - -fn shiftOne(v: u32, at: u32, removed: u32, inserted: u32) u32 { - if (v <= at) return v; - if (v <= at +| removed) return at +| inserted; - return v - removed +| inserted; -} - -/// How many of these bytes end on a character boundary. -/// -/// acme buffers a partial rune on the Fid (`fullrunewrite` plus `f->rpart`) -/// and stitches it onto the next write. A SHORT COUNT is the POSIX spelling of -/// the same promise — the writer's libc retries with the tail — and it needs -/// no per-handle state at all. Never zero for a -/// non-empty write: a writer handed 0 retries the same bytes forever. -fn wholeUtf8(data: []const u8) usize { - var i = data.len; - var back: usize = 0; - while (i > 0 and back < 4) : (back += 1) { - i -= 1; - const c = data[i]; - if (c < 0x80) return data.len; // an ASCII tail is always complete - if (c & 0xC0 == 0xC0) { // a lead byte: is its sequence all here? - const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len; - if (i + need <= data.len or i == 0) return data.len; - return i; - } - } - // four trailing continuation bytes and no lead: not UTF-8 at all. acme's - // `cvttorunes` substitutes for bad bytes rather than refusing them, and so - // does storing them verbatim. - return data.len; -} - -// =========================================================================== -// LOOKUP — acme's `fsyswalk`, minus 9P's fid bookkeeping. -// =========================================================================== - -/// A name inside a pane's directory. `.` and `..` are the kernel's business, -/// never ours, the directory variant is not nameable, and the three inside -/// `pty/` are not nameable HERE — their enum names carry a prefix precisely so -/// that `stringToEnum` cannot hand `7/pty_ctl` back as a file beside `body`. -/// `pty` itself resolves; whether it EXISTS is `attrOf`'s question. -fn paneFileNamed(name: []const u8) ?PaneFile { - const f = std.meta.stringToEnum(PaneFile, name) orelse return null; - if (f == .dir) return null; - return if (f.inPty() and f != .pty) null else f; -} - -/// ...and a name inside `pty/`, which is a separate namespace: `ctl` and -/// `data` mean different files on the two sides of the slash, which is the -/// whole reason `pty/` is a directory (see `PaneFile`). -fn ptyFileNamed(name: []const u8) ?PaneFile { - if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; - if (std.mem.eql(u8, name, "status")) return .pty_status; - if (std.mem.eql(u8, name, "data")) return .pty_data; - return null; -} - -fn topFileNamed(name: []const u8) ?TopFile { - const f = std.meta.stringToEnum(TopFile, name) orelse return null; - return if (f == .root) null else f; -} - -/// acme: "is it a numeric name? yes: it's a directory". A pane's directory is -/// named by its SERIAL, which is never reused, so a stale path can go stale -/// but can never come to mean a different pane. -fn serialNamed(name: []const u8) ?u32 { - if (name.len == 0 or name.len > 10) return null; - for (name) |c| if (c < '0' or c > '9') return null; - return std.fmt.parseInt(u32, name, 10) catch null; -} - -/// The smallest live serial greater than `after`, so a caller can walk every -/// pane in ascending serial without sorting anything. O(panes) per step over -/// at most sixteen slots, and no allocation — the alternative was a scratch -/// array in a function that must not allocate. -fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { - var best: ?u32 = null; - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.serial <= after) continue; - if (best == null or pane.serial < best.?) best = pane.serial; - } - return best; -} - -/// Create a pane the way the `New` builtin does — an empty scratch below the -/// active one, in its column — and answer its serial. -/// -/// acme has `newwindowthread` sitting on a channel for exactly this, and its -/// windows go wherever `rowadd` puts them. Going through `newScratchBelow` -/// means a pane a script opened is in every respect a pane you opened: same -/// tag, same builtins, same undo, same Del. -fn newPane(p: *Pardes) ?u32 { - const slot = p.freeSlot() orelse return null; - p.newScratchBelow(p.active); - const pane = p.panes[slot] orelse return null; - return pane.serial; -} - -fn lookup(p: *Pardes, req: Req, target: Target) Reply { - const name = req.data; - if (name.len == 0 or std.mem.indexOfScalar(u8, name, '/') != null) return Reply.fail(req.tag, E.NOENT); - const node: u64 = switch (target) { - .top => |f| switch (f) { - .root => root: { - if (topFileNamed(name)) |t| break :root @intFromEnum(t); - const serial = serialNamed(name) orelse return Reply.fail(req.tag, E.NOENT); - _ = p.paneBySerial(serial) orelse return Reply.fail(req.tag, E.NOENT); - break :root Node.of(serial, .dir); - }, - .new => new: { - // acme(4): "Accessing any file in new creates a new window." - // - // acme creates it one component EARLIER — `fsyswalk` sends on - // `cnewwindow` the moment it walks the name `new` itself. That - // cannot work over FUSE: the kernel CACHES the dentry for - // `new`, so a lookup there would fire once per mount and never - // again. Creating at the CHILD keeps the promise the man page - // makes (`echo hi > $PARDES_FS/new/body` opens a pane holding - // `hi`) under a protocol that caches. - // - // The name is checked BEFORE the pane is made, so a stat of - // `new/nosuchfile` leaves no litter. acme's walk creates the - // window first and then fails the second component, which - // leaves an empty window behind for every typo. - const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT); - // `new/` makes a SCRATCH pane, which is a document and never a - // terminal, so `new/pty` names something that cannot exist. - // Refused before the pane is made, for the same reason every - // other bad name here is: a typo must leave no litter. - if (want.inPty()) return Reply.fail(req.tag, E.NOENT); - const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); - break :new Node.of(serial, want); - }, - else => return Reply.fail(req.tag, E.NOTDIR), - }, - .pane => |t| pane: { - _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - // Two directories, two namespaces. `attrOf` below is what decides - // whether the `pty` half exists on this pane at all. - const f = switch (t.file) { - .dir => paneFileNamed(name), - .pty => ptyFileNamed(name), - else => return Reply.fail(req.tag, E.NOTDIR), - } orelse return Reply.fail(req.tag, E.NOENT); - break :pane Node.of(t.serial, f); - }, - }; - // A lookup answers with the TARGET's attributes, which is exactly what a - // getattr of that node would say — one spelling, so the two can never - // disagree about a size or a mode. - return switch (attrOf(p, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT))) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }; -} - -// =========================================================================== -// READDIR -// =========================================================================== - -/// One directory entry in the transport-neutral staging format `src/fuse.zig` -/// decodes: node id, kind, name length, name — packed, little-endian, no -/// padding. A readdir answer is that record repeated. -/// -/// `node` travels because it becomes the `d_ino` a `getdents64` reports, and a -/// `d_ino` that disagrees with the later `st_ino` is a filesystem that lies to -/// `find -inum`. -fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void { - if (name.len == 0 or name.len > 255) return; - var head: [10]u8 = undefined; - std.mem.writeInt(u64, head[0..8], node, .little); - head[8] = @intFromBool(dir); - head[9] = @intCast(name.len); - out.appendSlice(gpa, &head) catch return; - out.appendSlice(gpa, name) catch return; -} - -/// The pane files, for a pane directory. `pty/` is listed only on a terminal: -/// a file pane's listing is byte for byte what it was before that directory -/// existed, which is what keeps every existing script's `ls` unsurprised. -fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { - inline for (comptime std.enums.values(PaneFile)) |f| { - // The directory itself is never an entry, and the three names inside - // `pty/` belong to THAT directory's listing rather than to this one — - // the enum is flat, the tree is not. - if (comptime f == .dir or (f.inPty() and f != .pty)) continue; - // `pty/` itself is present only on a terminal. A runtime `continue` - // cannot leave an `inline for` body, so the entry is conditional - // rather than the iteration. - const present = f != .pty or terminal; - if (present) { - if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.name()); - } - } -} - -/// ...and the three inside `pty/`, in declaration order like every other -/// listing here, so a script that walks the tree twice can diff the walks. -fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { - inline for (comptime std.enums.values(PaneFile)) |f| { - if (comptime !f.inPty() or f == .pty) continue; - if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name()); - } -} - -fn readdir(p: *Pardes, req: Req, target: Target) Reply { - const out = p.fs.stage(p.gpa); - var skip = req.off; - switch (target) { - .top => |f| switch (f) { - .root => { - inline for (.{ TopFile.index, TopFile.cons, TopFile.new }) |t| { - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.name()); - } - // Ascending serial: serials are never reused, so this order is - // stable across a create and a delete — which is what a script - // that walks the tree twice and diffs the two walks needs. - // acme lists windows in SCREEN order (column by column), which - // changes when you drag a window and says nothing a script can - // rely on. - var last: u32 = 0; - while (nextSerialAfter(p, last)) |s| { - last = s; - if (skip > 0) { - skip -= 1; - continue; - } - var buf: [16]u8 = undefined; - const name = std.fmt.bufPrint(&buf, "{d}", .{s}) catch continue; - stageDirent(out, p.gpa, Node.of(s, .dir), true, name); - } - }, - // `new/` ENUMERATES NOTHING, and that is a guarantee rather than a - // shrug: the names it could list are exactly the names whose LOOKUP - // creates a pane, and every tool that lists a directory then stats - // what it found — `ls -l`, `ls --color`, `find`, a shell completing - // `$PARDES_FS/new/` — would make one pane per name. acme never - // lists it either. Naming a file here is what creates one; see - // `lookup`. - .new => {}, - else => return Reply.fail(req.tag, E.NOTDIR), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const terminal = p.panes[id].?.isTerminal(); - switch (t.file) { - .dir => stagePaneFiles(p, out, t.serial, terminal, &skip), - // A node id naming `pty/` can only have come from a pane that - // was a terminal when it was resolved. It may not be one now - // (a pane can acquire a document), so answer what a lookup - // would answer today rather than trusting the id. - .pty => { - if (!terminal) return Reply.fail(req.tag, E.NOENT); - stagePtyFiles(p, out, t.serial, &skip); - }, - else => return Reply.fail(req.tag, E.NOTDIR), - } - }, - } - // Zero bytes is END OF DIRECTORY, never an error: the transport stops - // asking, and re-staging from scratch on every call is what makes a - // partially consumed answer safe to ask for again at a higher cookie. - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// OPEN / RELEASE / SETATTR -// =========================================================================== - -/// Open carries no per-open state, because there is none to carry: `addr` and -/// `limit` belong to the pane (as they do in acme, where they are Window -/// fields), and every read brings its own offset. What an open DOES do is -/// arm the two things acme arms on open, and count the two kinds of reader. -/// -/// So there is no fid table. acme needs one because 9P walks to a fid and -/// every later message names only that fid; FUSE puts the nodeid on every -/// request, RELEASE included, so the handle is decoration. It is answered -/// non-zero only because the transport spells "no handle" as zero. -fn open(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => {}, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pf = &p.fs.panes[id]; - // A file that is not there cannot be opened, so the reader count - // below cannot be armed on a pane with no pty. Same answer - // `lookup`, `read` and `write` give (`attrOf`). - if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT); - switch (t.file) { - // acme(4): "When the ctl file is first opened, regular - // expression context searches in addr addresses examine the - // whole file"; `limit=addr` narrows them again. - .ctl => pf.limit = null, - // acme resets both on the FIRST open (`w->nopen[QWaddr]++ == - // 0`) and keeps a per-file open count to know. There is none - // here: `addr` is one piece of per-pane state that a second - // opener would be sharing anyway, so the honest reading of - // "first" is "whenever somebody opens it" — and a script's - // first act on `addr` is always to write one. - .addr => { - pf.addr = .{}; - pf.limit = null; - }, - // THE SUPPRESSION GATE. While this is non-zero the pane is - // script-driven: its Look and Exec are reported, not - // performed (`noteAction`). Counted per OPEN, not per pane, so - // two readers means the second one closing leaves the first - // still in charge. - .event => { - pf.readers +|= 1; - p.fs.listeners +|= 1; - }, - // THE OTHER GATE, and deliberately a separate count: while - // this is non-zero the raw pty bytes are copied into - // `pty_out` as they arrive (`notePtyOutput`). It does NOT - // touch `listeners` — reading a terminal's output stream is - // not claiming the pane's buttons, and a script that did both - // would have opened `event` too. - .pty_data => pf.pty_readers +|= 1, - else => {}, - } - }, - } - return .{ .tag = req.tag, .handle = 1 }; -} - -fn release(p: *Pardes, req: Req) Reply { - const target = Node.target(req.node) orelse return .{ .tag = req.tag }; - switch (target) { - .top => {}, - .pane => |t| { - if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; - // The pane may have DIED while this was open. `State.forget` has - // then already taken its whole reader count out of `listeners` - // (the core calls it from `deinitPane`), so a serial that no - // longer resolves must not be decremented a second time — that - // underflow is exactly what would leave the editor suppressing - // button actions forever with no script left to interpret them. - const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; - const pf = &p.fs.panes[id]; - if (t.file == .pty_data) { - if (pf.pty_readers == 0) return .{ .tag = req.tag }; - pf.pty_readers -= 1; - // The LAST pty reader leaving takes the queue's MEMORY with - // it, not merely its contents: `queue_cap` per pane held - // until the pane dies would be an editor that grew by being - // scripted once. And what is in it is stale anyway — the next - // reader wants the program's output from when IT opened the - // file, not a replay of somebody else's session. - if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa); - return .{ .tag = req.tag }; - } - if (pf.readers == 0) return .{ .tag = req.tag }; - pf.readers -= 1; - p.fs.listeners -|= 1; - // The LAST reader leaving takes the tag snapshot with it. It is - // only ever compared against while somebody is listening, so - // keeping it would let the tag drift unobserved and then hand the - // NEXT reader a `d`/`i` pair for a change it never saw. - if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); - }, - } - return .{ .tag = req.tag }; -} - -fn setattr(p: *Pardes, req: Req, target: Target) Reply { - // acme has NO equivalent: 9P has no truncate-on-open, so nothing in - // `xfid.c` answers a Twstat carrying a length. Linux does — `> body` is - // O_TRUNC — and refusing it would make the shell's most natural way to - // REPLACE a pane's text (rather than append to it) fail with EPERM on the - // redirect, before a single byte was written. So exactly one field is - // honoured, only the value zero means anything, and everything else a - // `stat` structure can carry (mode, owner, times) is silently accepted and - // ignored the way a filesystem of live editor state has to. - if (req.truncate) switch (target) { - .pane => |t| switch (t.file) { - .body, .data, .xdata => { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - if (fileOf(pane) != null) { - _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse - return Reply.fail(req.tag, E.NOMEM); - p.fs.panes[id].addr = .{}; - setDot(pane, .{}); - } - }, - else => {}, - }, - else => {}, - }; - return switch (attrOf(p, target)) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }; -} - -// =========================================================================== -// READ -// =========================================================================== - -/// Answer with a WINDOW onto what was just staged. `Payload.staged` is a -/// LENGTH from the start of the buffer, so a read at an offset slides the -/// bytes down rather than growing the payload union with a second field -/// nothing else would ever use. -fn staged(p: *Pardes, req: Req) Reply { - const out = &p.fs.out; - const off = @min(req.off, out.items.len); - const n = @min(out.items.len - off, req.size); - if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]); - out.shrinkRetainingCapacity(n); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; -} - -fn read(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| return switch (f) { - .index => readIndex(p, req), - // acme's dirtab: `cons` is 0200 and a directory is not read(2)able. - .cons, .root, .new => Reply.fail(req.tag, E.PERM), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - const pf = &p.fs.panes[id]; - // A `pty/` node whose pane is no longer a terminal reads as - // absent, not as empty: the same answer `lookup` gives today. - if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); - return switch (t.file) { - .addr => readAddr(p, req, pf, pane), - .body => readBody(p, req, id, pane), - .ctl => readCtl(p, req, pane), - .data => readData(req, id, pane, pf, false), - .xdata => readData(req, id, pane, pf, true), - .tag => readTag(p, req, pane), - .event => readQueue(p, req, &pf.events), - .rdsel => readRdsel(req, id, pane), - .pty_status => readPtyStatus(p, req, id, pane), - .pty_data => readPtyData(p, req, pf), - .dir, .errors, .wrsel, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM), - }; - }, - } -} - -/// acme's `Ctlsize`: five `%11d ` fields = 60 bytes, before the tag. -const ctl_fields = 5 * 12; - -/// acme's `winctlprint(w, buf, 0)` — the five numbers `index` and `ctl` share. -/// -/// COST: acme reads the tag's length off `w->tag.file->nc` for free, because -/// acme's tag IS a buffer. pardes's is COMPUTED every time it is asked for -/// (path, dirty marker, builtins, and the alignment gap, which is measured -/// against every other pane in the same layout column), so these five numbers -/// cost one tag render — a couple of microseconds and a few bumps of the -/// per-update scratch arena, which `Pardes.update` resets. That is the price -/// of the second field being the number a `tag` read will actually hand back; -/// a cheaper approximation that disagreed with `read tag` would be worse than -/// slow, it would be wrong. -fn stageCtlNumbers(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void { - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ - pane.serial, - tagOf(p, pane).len, - bodyOf(pane).len, - // acme's `isdir` marks a window holding a DIRECTORY LISTING. pardes - // never opens one — a Look at a directory spawns a shell there - // (look.zig) — so this is structurally zero, not unimplemented. - @as(u32, 0), - @intFromBool(dirtyOf(pane)), - }) catch {}; -} - -/// acme's `xfidindexread`: one line per pane, the five numbers then the tag up -/// to its first newline. Seekable, so a script can pread the middle of it — -/// "at character position 5×12 starts the name of the window" (acme(4)). -fn readIndex(p: *Pardes, req: Req) Reply { - const out = p.fs.stage(p.gpa); - var last: u32 = 0; - while (nextSerialAfter(p, last)) |s| { - last = s; - const pane = p.panes[p.paneBySerial(s).?].?; - stageCtlNumbers(p, out, pane); - out.appendSlice(p.gpa, firstLine(tagOf(p, pane))) catch {}; - out.append(p.gpa, '\n') catch {}; - } - return staged(p, req); -} - -/// acme: `sprint(buf, "%11d %11d ", w->addr.q0, w->addr.q1)`. acme's numbers -/// are RUNE offsets; these are bytes (see the header). "Thus a regular -/// expression may be evaluated by writing it to addr and reading it back." -fn readAddr(p: *Pardes, req: Req, pf: *PaneFs, pane: *Pane) Reply { - clampAddr(pf, bodyOf(pane).len); - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {}; - return staged(p, req); -} - -fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (pane.file != null) { - // ZERO COPY: `.region` is resolved by `fsPayload` during the effect - // drain, so reading a megabyte of body moves no bytes in here at all. - // This is the whole reason `Payload` is a union and not a slice. - const text = bodyOf(pane); - const off = @min(req.off, text.len); - const n = @min(text.len - off, req.size); - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(off), - .len = clip(n), - } } }; - } - // A TERMINAL has no such buffer. acme's body is always a `Text`; pardes's - // is a terminal emulator, and its "body" is the scrollback — which only - // becomes bytes when somebody renders the pages into lines. So it is - // produced, staged, and paid for per read. `win`'s transcript, read side. - const text = term_pane.screenTextAlloc(pane, p.gpa) catch - return Reply.fail(req.tag, E.NOMEM); - defer p.gpa.free(text); - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); - return staged(p, req); -} - -/// The face the shell was last asked to wear. acme owns its fonts and prints -/// the real one; the core only knows what it REQUESTED — on a tty the font -/// belongs to the terminal emulator and in the browser to the page — so it -/// prints that, or `default`, which is the same word the Debug overlay shows -/// for the same reason. -fn fontName(p: *Pardes) []const u8 { - const name = p.settings.font.effective_name.get(); - return if (name.len == 0) "default" else name; -} - -/// plan9's `%q` (`quotestrfmt`): a string with nothing special in it prints -/// bare, anything else is wrapped in single quotes with internal quotes -/// doubled. Load-bearing rather than decoration — a script splits the ctl line -/// into shell words, and a font name with a space in it is one word. -fn stageQuoted(out: *std.ArrayList(u8), gpa: std.mem.Allocator, s: []const u8) void { - const plain = s.len > 0 and for (s) |c| { - if (c <= ' ' or c == '\'') break false; - } else true; - if (plain) { - out.appendSlice(gpa, s) catch {}; - return; - } - out.append(gpa, '\'') catch {}; - for (s) |c| { - if (c == '\'') out.append(gpa, '\'') catch {}; - out.append(gpa, c) catch {}; - } - out.append(gpa, '\'') catch {}; -} - -/// acme's `winctlprint(w, buf, 1)`: index's five numbers plus three more. -fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - stageCtlNumbers(p, out, pane); - // acme prints `Dx(w->body.r)` — the body's width in PIXELS — and - // `w->body.maxtab`, a tab's width in pixels too. pardes is a CELL GRID: - // on a tty there is no pixel width to report at all, and on the two pixel - // shells the number a script actually wants is still how many characters - // fit. So both are CELLS. A script that would have divided by the font - // width to get columns gets columns without dividing. - out.print(p.gpa, "{d:>11} ", .{pane.cols}) catch {}; - stageQuoted(out, p.gpa, fontName(p)); - out.print(p.gpa, " {d:>11} ", .{config.tab_width}) catch {}; - return staged(p, req); -} - -fn readTag(p: *Pardes, req: Req, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; - return staged(p, req); -} - -/// acme's `xfidruneread`: hand back whole characters from the START of `addr` -/// and move `addr` to the null string just after them; `xdata` additionally -/// stops at the END of `addr` (acme passes `w->addr.q1` where `data` passes -/// `nc`). The file offset is ignored — `addr` is the position. -/// -/// "Whole characters" is acme's partial-rune rule; here it is a GRAPHEME -/// boundary, which is strictly stronger and is what every other offset in -/// pardes already respects. A read too small for the next grapheme returns -/// zero bytes rather than half of one — acme's `if(m == 0) break`. -fn readData(req: Req, id: usize, pane: *Pane, pf: *PaneFs, stop_at_end: bool) Reply { - const text = bodyOf(pane); - clampAddr(pf, text.len); - const q0: usize = pf.addr.q0; - // acme carries a "BUG: what should happen if q1 > q0?" here and answers by - // reading nothing. An inverted address is a legal thing to have written - // (`address()` never normalises), so the empty read is the answer. - const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len; - var end = @min(hi, q0 +| req.size); - end = @max(q0, modal.graphemeStart(text, end)); - // `data` collapses the address onto the point it read up to; `xdata` moves - // only q0 and KEEPS q1, because q1 is the stop address the man page - // promises ("reads stop at the end address") and the next chunked read has - // to be able to continue from where this one stopped. acme spells the same - // difference at xfid.c:331-341: QWdata assigns both, QWxdata only q0. - pf.addr.q0 = clip(end); - if (!stop_at_end) pf.addr.q1 = clip(end); - if (pane.file == null) return .{ .tag = req.tag }; - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(q0), - .len = clip(end - q0), - } } }; -} - -/// acme copies the selection into a TEMP FILE at open, with a comment -/// apologising for it, so a `|sort` cannot see the text change underneath. -/// There is no such window here: the whole request is one main-thread -/// transaction, nothing can run between the open and the read, and the bytes -/// go out of the pane unmoved. -fn readRdsel(req: Req, id: usize, pane: *Pane) Reply { - if (pane.file == null) return .{ .tag = req.tag }; - const text = bodyOf(pane); - const d = dotOf(pane); - const lo = @min(@as(usize, d.q0), text.len); - const hi = @max(lo, @min(@as(usize, d.q1), text.len)); - const off = @min(req.off, hi - lo); - const n = @min(hi - lo - off, req.size); - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(lo + off), - .len = clip(n), - } } }; -} - -/// ONE RECORD PER READ, and `Status.again` when there is none. -/// -/// This is the whole of what acme's blocking `event` read becomes. acme parks -/// the `Xfid` in `w->eventx` and `winevent` sends it a message to wake it up; -/// the waiting lives in a thread per in-flight request, and `xfidflush` exists -/// to cancel one. Here nothing is consumed and nothing is remembered: the -/// transport still holds the kernel's request and asks again. No waiter list, -/// no wakeup, no flush bookkeeping, and no loop anywhere in the core. -fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { - const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; - // acme hands back as much of its event buffer as the count allows and - // keeps the rest, which can split a record down the middle; a reader is - // simply expected never to ask for less than one. Refusing is the honest - // version of that contract — half a record is unparseable and silently - // desynchronises the reader for the rest of the session. - if (req.size < record.len) return Reply.fail(req.tag, E.INVAL); - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); - q.pop(); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// `pty/` — the terminal a pane is, as three files. No prior art: acme has no -// terminals and `ad` has no terminal surface at all, so nobody has made these -// mistakes for us and nobody's scripts expect a particular spelling. Which is -// the argument for three files and no fourth. -// =========================================================================== - -/// `pty/status`: `%11d `-formatted, exactly like `ctl` and `index`, so a -/// script splits it the same way and `read`s it at an offset. -/// -/// THREE NUMBERS, and the choice of which three is the whole content of this -/// function. The core knows the grid it asked for and it can ask the host who -/// holds the tty; that is all it knows, and inventing a fourth field would be -/// inventing the number behind it. -/// -/// cols, rows the grid, in cells. What `TIOCGWINSZ` would answer, and the -/// same pair `winsize` sets — so a script can set a size and -/// read back that it took. -/// taken 1 while a PROGRAM holds the tty (vim, a pager, a build), 0 -/// at the shell's own prompt. `pull_tty_taken`, the probe the -/// core already asks before it types a command line; a host -/// that cannot tell says 0, which is how pardes behaved before -/// the probe existed. -/// -/// WHAT IS NOT HERE, and why not, because a missing field is a fact about the -/// core rather than an omission: -/// -/// exit status NOT TRACKED ANYWHERE. A shell's death arrives as -/// `Event.eof`, whose whole handler is `removePane` — the pane -/// and its serial are gone, so by the time anybody could read -/// a status file there is no directory to read it in. Reporting -/// a zero here would be reporting a number the core does not -/// have. Giving the pane an exit status means keeping the pane -/// alive past its child, which is a change to what a terminal -/// pane IS and does not belong in a status file's formatter. -/// raw/cooked the draft's `TCSETS` line. The core never sets a termios: -/// the mode belongs to the program on the far side of the pty, -/// which sets it for itself and never tells us. There is -/// nothing to report and nothing to set. -fn readPtyStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ - pane.cols, - pane.rows, - @intFromBool(p.hostTtyTaken(id)), - }) catch {}; - return staged(p, req); -} - -/// `pty/data`, read side: THE RAW OUTPUT STREAM, as a stream. -/// -/// FRAMING, which is the one decision here. `event` refuses a read smaller -/// than one record because half a record is unparseable. Raw pty bytes have no -/// records: what is in the queue is only "what arrived in one `.output` -/// event", which is wherever the host's `read(2)` happened to land, so -/// refusing a short read would be enforcing a boundary that means nothing — -/// and a reader with a 1 KB buffer would deadlock against a 4 KB arrival -/// forever. So this hands back as much as the count allows, spanning arrivals, -/// and keeps the remainder (`Queue.popFront`). That is what `read(2)` on the -/// pty itself would do. -/// -/// The OFFSET is ignored, for the same reason `event`'s is: the queue is the -/// position. And an empty queue is `Status.again` — nothing consumed, ask me -/// again — which is the whole of how a blocking read works here. -/// -/// A pane nobody has OPENED this file on has an empty queue by construction -/// (`notePtyOutput` is gated on the count `open` keeps), so a read that beats -/// the first byte of output and a read on a pane that never recorded any are -/// the same cheap answer. -fn readPtyData(p: *Pardes, req: Req, pf: *PaneFs) Reply { - if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; - const out = p.fs.stage(p.gpa); - while (out.items.len < req.size) { - const chunk = pf.pty_out.peek() orelse break; - const n = @min(chunk.len, req.size - out.items.len); - out.appendSlice(p.gpa, chunk[0..n]) catch break; - pf.pty_out.popFront(n); - } - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// WRITE -// =========================================================================== - -fn write(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| return switch (f) { - // acme(4): text written to `cons` appears in `dir/+Errors`, where - // `dir` is the directory the command ran in — acme knows which - // from the mount the writer inherited (`x->f->mntdir`, one per - // `win`). A FUSE mount is ONE directory for the whole editor, so - // the writing process is anonymous and the only defensible owner - // is the pane the user is in. A script that wants a specific - // pane's errors writes `<id>/errors`, which is unambiguous. - .cons => if (appendErrors(p, p.active, req.data)) |took| - .{ .tag = req.tag, .written = @intCast(took) } - else - Reply.fail(req.tag, E.IO), - else => Reply.fail(req.tag, E.PERM), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); - return switch (t.file) { - .addr => writeAddr(p, req, id, pane), - .body => writeBody(p, req, id, pane), - .ctl => writeCtl(p, req, t.serial), - // acme's `data` and `xdata` differ only in what a READ stops - // at; the writes are the same code path there and here. - .data, .xdata => writeData(p, req, id, pane), - .tag => writeTag(p, req, pane), - .event => writeEvent(p, req, id), - .wrsel => writeWrsel(p, req, id, pane), - .errors => if (appendErrors(p, id, req.data)) |took| - .{ .tag = req.tag, .written = @intCast(took) } - else - Reply.fail(req.tag, E.IO), - .pty_ctl => writePtyCtl(p, req, id), - .pty_data => writePtyData(p, req, id), - .dir, .rdsel, .pty, .pty_status => Reply.fail(req.tag, E.PERM), - }; - }, - } -} - -/// THE ONE BODY SPLICE every writing file goes through: replace `[q0, q1)` -/// with `bytes`, via `file_pane.setContent` — which is where the core diffs -/// out the insert/delete event records, so a script's edit is reported exactly -/// once and in exactly the same shape as a keystroke's. One swap per write for -/// the same reason: two swaps would be two `D`/`I` pairs for one write. -/// -/// The origin character the records carry is `handle`'s, set once per request -/// (acme's winlock owner), so nothing here has to know which file it is -/// serving. -fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, bytes: []const u8) ?usize { - const f = fileOf(pane) orelse return null; - const take = if (bytes.len == 0) 0 else wholeUtf8(bytes); - const lo = @min(q0, f.content.len); - const hi = @max(lo, @min(q1, f.content.len)); - const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null; - @memcpy(new[0..lo], f.content[0..lo]); - @memcpy(new[lo..][0..take], bytes[0..take]); - @memcpy(new[lo + take ..], f.content[hi..]); - // acme: `if(w->nomark == FALSE){ seq++; filemark(t->file); }` — `nomark` - // is how a script makes a batch of edits one Undo. - // - // COST, and the reason `nomark` matters more here than it does in acme: - // acme's `filemark` is a sequence number on a log-structured, disk-backed - // Buffer, so it is O(1). pardes's undo is a SNAPSHOT of the whole body - // (`file_pane.pushUndo` compares and then duplicates it), so a script that - // appends a line at a time to a megabyte body pays a megabyte per line and - // keeps 256 of them. That is exactly the same cost one KEYSTROKE pays on - // the same body — this is not a filesystem tax, it is the core's edit - // model — but a script can do it ten thousand times a second where a - // typist cannot. `nomark` is the documented remedy and the reason acme - // gave scripts the verb. - if (!p.fs.panes[id].nomark) file_pane.pushUndo(p, pane); - file_pane.setContent(p, f, new); - return take; -} - -/// acme(4): "Text written to body is always appended; the file offset is -/// ignored." So `req.off` is deliberately never read here. -fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - // A TERMINAL's body is not a document, it is a program's transcript — and - // the only way to put text into a transcript is to TYPE it. So a body - // write to a terminal pane is a pty write: `echo ls > $PARDES_FS/3/body` - // runs ls in pane 3's shell. That is `win`'s semantics in acme (the shell - // reads what you write to its window's body), reached through the effect - // the core already has instead of through a pipe. - // - // Nothing is RECORDED for it: the insert/delete diff lives in - // `file_pane.setContent`, and a terminal has no `file` to swap. The - // program's output comes back as ordinary `.output` bytes. - if (pane.file == null) { - const take = wholeUtf8(req.data); - p.emitWrite(id, req.data[0..take]); - return .{ .tag = req.tag, .written = @intCast(take) }; - } - const at = bodyOf(pane).len; - const take = spliceBody(p, id, pane, at, at, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - if (!p.fs.panes[id].noscroll) showOffset(pane, at + take); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's tag is one `Text` and a write appends to all of it. pardes's tag is -/// PREFIX ++ TAIL: the prefix is chrome the core recomputes every frame (the -/// path, the dirty marker, the builtin words, the alignment gap), so bytes -/// appended to it would be gone by the next render. A tag write therefore -/// appends to the TAIL — which is the part that is a buffer, and the part a -/// script means when it writes ` Undo` into a tag. -/// -/// The tail is a fixed one-line buffer (`Pane.tag_tail`), so a write that does -/// not fit is short, and one with no room at all is ENOSPC rather than a zero -/// count the writer would retry forever. -fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - // the laid-out default tail becomes real bytes on first touch, exactly as - // it does when you click into the tag - p.seedTail(pane); - const room = pane.tag_tail.len - pane.tag_tail_len; - if (room == 0) return Reply.fail(req.tag, E.NOSPC); - const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); - @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]); - pane.tag_tail_len += take; - pane.tag_init = true; - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme(4): text written to `data` "replaces the characters addressed by the -/// addr file and sets the address to the null string at the end of the written -/// text". The file offset is ignored. -fn writeData(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); - const pf = &p.fs.panes[id]; - clampAddr(pf, bodyOf(pane).len); - const q0: usize = pf.addr.q0; - const q1: usize = @max(q0, @as(usize, pf.addr.q1)); - const before = dotOf(pane); - // acme's winlock(w, 'F'): everything but body and tag is "an action - // through the window's other files". - const take = spliceBody(p, id, pane, q0, q1, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - setDot(pane, shiftBy(before, clip(q0), clip(q1 - q0), clip(take))); - pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; - if (!pf.noscroll) showOffset(pane, q0 + take); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's `wrsel` cuts the selection when the file is OPENED and inserts each -/// write at a running point after it (`w->wrselrange`). Same result, no -/// open-time mutation: each write REPLACES the selection, and because the -/// selection is left collapsed just after the inserted text, a second write -/// appends to the first exactly as `wrselrange` does. The only difference is -/// what an open and close with NO write does — acme has already emptied the -/// selection by then, this leaves the pane untouched. A filesystem that edits -/// your document when you `stat` it is a filesystem you cannot explore. -/// -/// acme also forces `nomark` for the file's lifetime so the whole stream is -/// one Undo. That needs open-time state we do not keep; a script that wants it -/// writes `nomark` to `ctl`, which is the same button with a name on it. -fn writeWrsel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); - const d = dotOf(pane); - const q0: usize = d.q0; - const q1: usize = @max(q0, @as(usize, d.q1)); - const take = spliceBody(p, id, pane, q0, q1, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - setDot(pane, .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's `xfidwrite` QWaddr. Two failures, and acme has two error strings for -/// them: `Ebadaddr` (the parser stopped before the end of the expression) and -/// `Eaddr` (it parsed but did not evaluate — out of range, or no match). A -/// filesystem has one channel for "no", so both are EINVAL. -fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - const pf = &p.fs.panes[id]; - const text = bodyOf(pane); - clampAddr(pf, text.len); - // acme's parser stops at a newline of its own accord (`\n` reaches the - // `default:` arm), which is what lets `echo '/foo/' > addr` work from a - // shell. Trimming says the same thing without threading it through every - // arm of the state machine. - const expr = std.mem.trimEnd(u8, req.data, "\n"); - var a: Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; - const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL); - if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL); - pf.addr = r; - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -// =========================================================================== -// THE ADDRESS LANGUAGE — acme's addr.c, byte-addressed. -// =========================================================================== - -/// mvzr PANICS on a pattern that ends inside an escape: `parseCharSet` slices -/// `in[i+1..]` and `valueFor` indexes `[0]` of it, so a trailing backslash is -/// an out-of-bounds read rather than a compile failure (pardes.zig's -/// `applySelRegex` carries the same warning about the prefix `[^\`). A live -/// typist can only reach that by accident; a SCRIPT's regex is untrusted -/// input, so it is screened here before the engine ever sees it. -fn safePattern(pat: []const u8) bool { - var i: usize = 0; - while (i < pat.len) : (i += 1) { - if (pat[i] != '\\') continue; - if (i + 1 >= pat.len) return false; - i += 1; - } - return true; -} - -/// THE ADDRESS PARSER, in acme's shape: one left-to-right pass with three -/// pieces of state — a running range, a DIRECTION (`+`/`-`/none) and a SIZE -/// (line or character) — recursing once per `,` or `;`. -/// -/// What is gone is the C. acme reads the expression through a `getc` callback -/// over a `Rune*` so one parser can serve both the filesystem and the Edit -/// language; it reports failure through two out-parameters (`evalp` for "did -/// not evaluate", `qp` for "stopped here") because it cannot return three -/// things; and it grows the regex pattern with `runerealloc` one rune at a -/// time. Here the expression is a slice, the cursor is a field, a pattern is a -/// subslice of the expression, and "did not evaluate" is `null`. -const Addr = struct { - text: []const u8, - /// `limit=addr`: regex context searches are confined to this. acme applies - /// it FORWARDS only, and so does this. - lim: ?PaneFs.Range, - expr: []const u8, - i: usize = 0, - /// One frame per `,` or `;`. acme recurses without a bound, which is fine - /// when the expression came from a person typing into a tag and is a - /// STACK OVERFLOW when it came from a script: `,,,,,...` a hundred - /// thousand deep is one write(2). A compound address deeper than this is - /// not an address anybody meant. - depth: u8 = 0, - - const max_depth = 32; - const Size = enum { char, line }; - - /// acme's `address()`. `ar` is what `.` means — and `xfidwrite` passes - /// `w->addr`, NOT the user's selection, so `.` is the CURRENT ADDRESS and - /// `addr=dot` is the only door the selection comes in by. (acme(4) - /// describes the language as "the format understood by button 3", where - /// `.` is dot; the code is the authority and this follows the code.) - fn address(a: *Addr, ar_in: PaneFs.Range) ?PaneFs.Range { - const start = a.i; - var ar = ar_in; - var r = ar_in; - var dir: u8 = 0; - var size: Size = .line; - var c: u8 = 0; - while (a.i < a.expr.len) { - const prevc = c; - c = a.expr[a.i]; - a.i += 1; - switch (c) { - ',', ';' => { - // `;` differs from `,` in one way: it makes the RIGHT side - // relative to the left one. - if (c == ';') ar = r; - if (prevc == 0) r.q0 = 0; // lhs defaults to 0 - if (a.i >= a.expr.len) { - r.q1 = clip(a.text.len); // rhs defaults to $ - } else { - if (a.depth >= max_depth) return null; - a.depth += 1; - const nr = a.address(ar) orelse return null; - a.depth -= 1; - r.q1 = nr.q1; - } - return r; - }, - '+', '-' => { - // a pending `+`/`-` with no count of its own means one - // line, unless what follows is itself an operand - if (prevc == '+' or prevc == '-') { - const nc = if (a.i < a.expr.len) a.expr[a.i] else 0; - if (nc != '#' and nc != '/' and nc != '?') - r = a.number(r, 1, prevc, .line) orelse return null; - } - dir = c; - }, - '.', '$' => { - // both are only meaningful as the FIRST character of a - // (sub)expression; anywhere else they end the parse - if (a.i != start + 1) { - a.i -= 1; - return r; - } - r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) }; - dir = if (a.i < a.expr.len) '+' else 0; - }, - '#', '0'...'9' => { - var digit = c; - if (c == '#') { - if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') { - a.i -= 1; - return r; - } - digit = a.expr[a.i]; - a.i += 1; - size = .char; - } - var n: u64 = digit - '0'; - while (a.i < a.expr.len) : (a.i += 1) { - const d = a.expr[a.i]; - if (d < '0' or d > '9') break; - n = @min(n * 10 + (d - '0'), std.math.maxInt(u32)); - } - r = a.number(r, @intCast(n), dir, size) orelse return null; - dir = 0; - size = .line; - }, - '/', '?' => { - const back = c == '?'; - r = a.regexp(r, a.pattern(c), back) orelse return null; - dir = 0; - size = .line; - }, - else => { - a.i -= 1; - return r; - }, - } - } - // a trailing `+` or `-` with nothing after it: one line that way - if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null; - return r; - } - - /// The pattern between the delimiters, with the backslash of an escape - /// KEPT (it belongs to the regex engine, not to this parser). - /// - /// DIVERGENCE: acme closes both `/re/` and `?re?` on a `/` — its scanner - /// has no `case '?'` at all, so `?foo?` yields the pattern `foo?`, which - /// as a regex means `fo` plus an optional `o`. That is a bug you can only - /// find by reading addr.c. Here the OPENING delimiter closes. - fn pattern(a: *Addr, delim: u8) []const u8 { - const s = a.i; - while (a.i < a.expr.len) { - const c = a.expr[a.i]; - if (c == '\n') break; - a.i += 1; - if (c == '\\') { - if (a.i < a.expr.len) a.i += 1; - continue; - } - if (c == delim) return a.expr[s .. a.i - 1]; - } - return a.expr[s..a.i]; - } - - /// acme's `number()`, byte for byte — including its two oddities: a `-` - /// count from offset 0 wraps to the END of the file, and `:1-1` is legal - /// (it means `#0`) while `:1-2` is an error. - fn number(a: *Addr, r_in: PaneFs.Range, n: u32, dir: u8, size: Size) ?PaneFs.Range { - var r = r_in; - if (size == .char) { - var off: i64 = n; - if (dir == '+') { - off = @as(i64, r.q1) + n; - } else if (dir == '-') { - if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len); - off = @as(i64, r.q0) - n; - } - if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null; - // BYTES, and a byte offset can land inside a grapheme where acme's - // rune offset never could. Clamped to the boundary at or before - // it, which is the rule every other offset in pardes follows. - const g = clip(modal.graphemeStart(a.text, @intCast(off))); - return .{ .q0 = g, .q1 = g }; - } - var line: i64 = n; - var q0: usize = r.q0; - var q1: usize = r.q1; - switch (dir) { - '-' => { - if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') { - q0 -= 1; - }; - q1 = q0; - while (line > 0 and q0 > 0) { - if (a.text[q0 - 1] == '\n') { - line -= 1; - q1 = q0; - } - q0 -= 1; - } - if (line > 1) return null; - while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1; - return .{ .q0 = clip(q0), .q1 = clip(q1) }; - }, - '+' => { - if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') { - q1 += 1; - }; - q0 = q1; - }, - else => { - q0 = 0; - q1 = 0; - }, - } - while (line > 0 and q1 < a.text.len) { - const ch = a.text[q1]; - q1 += 1; - if (ch == '\n' or q1 == a.text.len) { - line -= 1; - if (line > 0) q0 = q1; - } - } - if (line > 0) return null; - return .{ .q0 = clip(q0), .q1 = clip(q1) }; - } - - /// acme's `regexp()`. Forward runs from the END of the running range to - /// the limit (`limit=addr`, else the end of the file); backward runs from - /// its START back to the beginning. - /// - /// The engine is mvzr, the one `%s` and the selection previews already - /// use. Two of its properties come along and cannot be fixed here: `^` and - /// `$` assert against the SLICE being searched rather than against a line, - /// and `.` matches a newline like any other byte. Both are already waived - /// in pardes.zig; an address that needs a line anchor matches `\n`. - fn regexp(a: *Addr, r: PaneFs.Range, pat: []const u8, back: bool) ?PaneFs.Range { - // acme reuses the LAST compiled expression for an empty pattern - // (`rxnull`). There is no such global here — one more piece of hidden - // state for a script to guess wrong about — so `//` is not an address. - if (pat.len == 0 or !safePattern(pat)) return null; - const re = mvzr.compile(pat) orelse return null; - if (back) { - const hi = @min(@as(usize, r.q0), a.text.len); - var best: ?mvzr.Match = null; - var at: usize = 0; - while (at < hi) { - const m = re.matchPos(at, a.text[0..hi]) orelse break; - best = m; - at = if (m.end > m.start) m.end else m.end + 1; - } - const m = best orelse return null; - return .{ .q0 = clip(m.start), .q1 = clip(m.end) }; - } - const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len; - const from = @min(@as(usize, r.q1), hi); - const m = re.match(a.text[from..hi]) orelse return null; - return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) }; - } -}; - -// =========================================================================== -// CTL VERBS — acme's xfidctlwrite. -// =========================================================================== - -/// The verbs that mean something here. acme matches PREFIXES with `strncmp` -/// and advances by the matched length, which is why its arms have to be -/// ordered `delete` before `del`, `nomark` before `mark`, `noscroll` before -/// `scroll` — get that ordering wrong and a verb is silently truncated into a -/// different one. Splitting on the newline the man page already requires and -/// matching WHOLE tokens makes that class of bug unrepresentable. -const Verb = enum { - @"addr=dot", - clean, - cleartag, - del, - delete, - dirty, - @"dot=addr", - get, - @"limit=addr", - mark, - nomark, - noscroll, - put, - scroll, - show, -}; - -/// ...and the ones acme has that pardes REFUSES. Loudly, because a silently -/// accepted no-op is the worse failure: the script believes it holds the lock. -/// -/// menu / nomenu — acme maintains `Undo Redo Put` in the LEFT HALF of the -/// tag and these switch that off. pardes's tag prefix is computed chrome -/// (the path, the dirty marker, the pane's own builtins) with no halves -/// and no writable menu region, so there is nothing to switch. -/// dump / dumpdir — acme's dump file stores a COMMAND that recreates a -/// window. pardes's dump (src/dump.zig) stores the window's TEXT, so a -/// recreation command has nowhere to be kept and nothing to run it. -/// font — the face belongs to the SHELL, not the core: on a tty it is the -/// terminal emulator's and in the browser it is the page's. The `Font` -/// builtin only ASKS; a ctl verb that looked like it set one would be a -/// lie on three of the four platforms. -/// lock / unlock — acme's exclusive-use lock is a `QLock` held against a 9P -/// fid. There is no fid here and the core is single-threaded, so a lock -/// would promise a mutual exclusion nothing can violate and nothing -/// provides. -const refused_verbs = [_][]const u8{ "dump", "dumpdir", "font", "lock", "menu", "nomenu", "unlock" }; - -fn verbIs(line: []const u8, word: []const u8) bool { - if (!std.mem.startsWith(u8, line, word)) return false; - return line.len == word.len or line[word.len] == ' '; -} - -/// acme's ctl write is NOT atomic: it applies verbs until one fails, then -/// answers `Ebadctl` with a count of the bytes it got through, so -/// `dirty\nbogus\n` leaves the window dirty and the write "fails". A short -/// count on a Linux write is not read as "the rest failed" by anybody, so the -/// only honest translation is all-or-nothing: validate every verb first, then -/// apply. `ctlVerb` answers the same yes/no in both passes. -fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { - for ([2]bool{ false, true }) |apply| { - // `del`'s guard is the one predicate that reads state EARLIER VERBS IN - // THE SAME WRITE change, so the validation pass has to model it or the - // two passes disagree: `clean\ndel` (acme's own idiom, and what - // examples/acmefs/life.py sends on the way out) would fail validation - // while `dirty\ndel` would pass it and then fail half-applied. - var dirty = if (p.paneBySerial(serial)) |id| dirtyOf(p.panes[id].?) else false; - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - // `del` and `delete` remove the pane, and the verbs after them in - // the same write have nothing left to act on. - const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); - if (!ctlVerb(p, live, line, apply, &dirty)) return Reply.fail(req.tag, E.INVAL); - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -/// One verb. `apply` false is the validation pass and must change nothing but -/// `dirty`, which both passes advance identically so that `del`'s guard sees -/// the same answer in each. -fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) bool { - const pane = p.panes[id] orelse return false; - const pf = &p.fs.panes[id]; - - // The one verb with an argument. acme rejects a name containing any - // character `<= ' '` and an empty one; so does this. - if (verbIs(line, "name")) { - if (line.len <= 5) return false; - const name = std.mem.trim(u8, line[5..], " \t"); - if (name.len == 0) return false; - for (name) |c| if (c <= ' ') return false; - if (!apply) return true; - const f = fileOf(pane) orelse return true; // a terminal has no name to set - const copy = p.gpa.dupe(u8, name) catch return true; - p.gpa.free(f.path); - f.path = copy; - return true; - } - for (refused_verbs) |w| if (verbIs(line, w)) return false; - - const v = std.meta.stringToEnum(Verb, line) orelse return false; - // acme: `del` is "delete, but check dirty", `delete` is "delete for sure". - // pardes's `Del` builtin is unconditional (the guard there is the `*` you - // can see in the tag), so `del` gets acme's guard here and `delete` does - // not — which is the whole difference between the two words. - if (v == .del and dirty.*) return false; - switch (v) { - .dirty => dirty.* = true, - .clean, .get, .put => dirty.* = false, - else => {}, - } - if (!apply) return true; - - switch (v) { - .@"addr=dot" => pf.addr = dotOf(pane), - .@"dot=addr" => { - clampAddr(pf, bodyOf(pane).len); - setDot(pane, pf.addr); - }, - .@"limit=addr" => { - clampAddr(pf, bodyOf(pane).len); - pf.limit = pf.addr; - }, - // acme marks the window clean by resetting the file's sequence number; - // pardes's equivalent is "the revision on screen IS the saved one". - .clean => if (fileOf(pane)) |f| { - f.saved_revision = f.revision; - }, - .dirty => if (fileOf(pane)) |f| { - f.saved_revision = f.revision -% 1; - }, - // acme: "wipe tag right of bar". pardes's bar is the boundary between - // the computed prefix and the editable tail, so this empties the tail - // — and leaves it SEEDED, or the next render would put the default - // builtins straight back. - .cleartag => { - pane.tag_tail_len = 0; - pane.tag_init = true; - }, - .del, .delete => _ = p.executeBuiltinLine(id, "Del"), - .put => _ = p.executeBuiltinLine(id, "Save"), - // acme's `get`: "Equivalent to the Get interactive command with no - // arguments". pardes has no such builtin, so this is what Get would - // be — the same synchronous read `file_pane.open` does, through the - // same content swap, with an undo point in front of it so a script - // cannot discard your edits irrecoverably. - .get => if (fileOf(pane)) |f| { - if (output_pane.fileTraits(f.output).saves) { - if (look.readFile(p.gpa, f.path)) |bytes| { - file_pane.pushUndo(p, pane); - file_pane.setContent(p, f, bytes); - f.saved_revision = f.revision; - } else |_| {} - } - }, - // acme's `mark` both cancels `nomark` AND pushes a mark, so the edits - // made while nomark was on stay one Undo and the next one starts fresh. - .mark => { - pf.nomark = false; - file_pane.pushUndo(p, pane); - }, - .nomark => pf.nomark = true, - .noscroll => pf.noscroll = true, - .scroll => pf.noscroll = false, - .show => showOffset(pane, dotOf(pane).q0), - } - return true; -} - -// =========================================================================== -// `pty/ctl` VERBS — the ioctls, as words. -// =========================================================================== - -/// THE WHOLE GRAMMAR, one verb per line, blank lines ignored, each line -/// trimmed and split on blanks: -/// -/// winsize <cols> <rows> two decimals, each 1..65535 -/// sig <NAME> one of INT, TERM, HUP, QUIT, KILL -/// exec no argument -/// -/// An enum and an exhaustive switch for the same reason `Verb` above is one: -/// adding a word is a compile error until it is handled, and matching WHOLE -/// tokens makes acme's ordering bug (`del` shadowing `delete`) unrepresentable. -const PtyVerb = enum { winsize, sig, exec }; - -/// A `winsize` field. -/// -/// ZERO IS REFUSED. `TIOCSWINSZ` reads a zero as "unknown", so `winsize 0 24` -/// would not be a narrow terminal, it would be a terminal of no known width — -/// which is what a program sees when nobody has set a size at all, and never -/// something a script asked for on purpose. -fn ptyDimension(word: []const u8) ?u16 { - if (word.len == 0 or word.len > 5) return null; - for (word) |c| if (c < '0' or c > '9') return null; - const n = std.fmt.parseInt(u16, word, 10) catch return null; - return if (n == 0) null else n; -} - -/// `sig`'s argument: the five names, upper case, spelled the way `kill -INT` -/// and `trap` spell them. -/// -/// NOT A NUMBER, and not `SIGINT` either. A number would be one platform's -/// number in a tree meant to be read from another machine, and the core has no -/// signal numbers of its own (see `pardes.PtySignal`); the `SIG` prefix has -/// been optional to `kill` since 1988 and carrying it here would mean -/// accepting both spellings or refusing the shorter one people type. -fn ptySignalNamed(word: []const u8) ?pardes.PtySignal { - if (std.mem.eql(u8, word, "INT")) return .int; - if (std.mem.eql(u8, word, "TERM")) return .term; - if (std.mem.eql(u8, word, "HUP")) return .hup; - if (std.mem.eql(u8, word, "QUIT")) return .quit; - if (std.mem.eql(u8, word, "KILL")) return .kill; - return null; -} - -/// VALIDATE EVERY VERB, THEN APPLY — `writeCtl`'s shape, for `writeCtl`'s -/// reason: acme applies verbs until one fails and answers with a byte count of -/// how far it got, and nothing on Linux reads a short count on a `write(2)` as -/// "the rest failed", so all-or-nothing is the only honest translation. -/// -/// Simpler than `writeCtl` in exactly one way, and it is worth saying why the -/// two passes need no shared bookkeeping here: no verb in this file can remove -/// the pane or change what a later verb in the same write would decide. `ctl` -/// has `del`, whose guard reads state `clean` sets, so its passes have to -/// model each other; these three are independent, so the validation pass is a -/// pure predicate. -fn writePtyCtl(p: *Pardes, req: Req, id: usize) Reply { - for ([2]bool{ false, true }) |apply| { - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - if (!ptyVerb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -/// One `pty/ctl` verb. `apply` false is the validation pass and must change -/// nothing whatsoever — not even a queued effect, which is the only state -/// these three touch. -fn ptyVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { - const pane = p.panes[id] orelse return false; - var words = std.mem.tokenizeAny(u8, line, " \t"); - // the line is non-empty and trimmed, so there is always a first token - const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false; - switch (v) { - // `TIOCSWINSZ`, and DELIBERATELY NOTHING ELSE — in particular not the - // core's own grid. - // - // A pane's grid size is not a free variable here: `Pardes.sync` derives - // `pane.cols`/`pane.rows` from the pane's RECTANGLE at the end of every - // update, so a script that wrote them would have them overwritten - // before its write returned — and `sync` would then emit a second - // `resize_pty` putting the pty back to the layout's size, so the verb - // would visibly undo itself. Telling only the pty leaves the script's - // size in force until the pane's rectangle actually changes, which for - // a layout nobody is dragging is for good. - // - // Which is also why a `winsize` write is not read back from `status`: - // `status` reports the grid the editor computed, the only size the core - // has. What a program was last TOLD is remembered by the pty, and the - // pty will not say. - .winsize => { - const cols = ptyDimension(words.next() orelse return false) orelse return false; - const rows = ptyDimension(words.next() orelse return false) orelse return false; - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); - }, - // The one genuinely new capability in the whole `pty/` directory: - // there is no `kill` anywhere in the host seam until this effect. - .sig => { - const which = ptySignalNamed(words.next() orelse return false) orelse return false; - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); - }, - // RESPAWN THIS PANE'S SHELL, and NO ARGUMENT — which is a limit of the - // effect and not a choice made here. `Effect.spawn` carries a pane and - // a cwd (pardes.zig) and has nowhere to put an argv; the host answers - // it by forking `core.shellBin()`, and the argv it builds is the - // prompt-integration rc files, not something a caller supplies. So - // `exec` respawns the configured shell in the pane's own directory, - // and `exec /bin/sh` is EINVAL — refused loudly rather than accepted - // and silently ignored, which is the failure a script cannot see. - // - // Giving it an argv means widening the effect and teaching four hosts - // to exec something the user did not configure, which is a change to - // what a terminal pane IS and wants its own argument. - // - // The host reaps the old child and forks a new one (every `push_spawn` - // opens by doing exactly that, because the core has no close effect). - // The GRID is not cleared: a terminal's body is a transcript, and the - // transcript of the shell that just died is the thing a script would - // want to read afterwards. - .exec => { - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); - }, - } - return true; -} - -/// `pty/data`, write side: TYPE AT THE PROGRAM. -/// -/// Identical to what a `body` write to a terminal already does (`writeBody`), -/// and that is the point of the name rather than a duplication: `body` is a -/// pty write because a transcript can only be written by typing, `pty/data` is -/// a pty write because it IS the pty. A script that knows it is talking to a -/// terminal says so; one that is generic over panes writes `body`. -/// -/// The offset is ignored — a stream has no offsets — and the count is short at -/// a character boundary exactly as every other write here is, so a caller -/// whose buffer was split mid-sequence by the kernel's `max_write` retries the -/// tail instead of having it dropped. -fn writePtyData(p: *Pardes, req: Req, id: usize) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - const take = wholeUtf8(req.data); - p.emitWrite(id, req.data[0..take]); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -// =========================================================================== -// EVENT WRITE-BACK — acme's xfideventwrite. -// =========================================================================== - -const EventRecord = struct { action: Action, q0: u32, q1: u32 }; - -/// `{origin}{type}{q0} {q1}\n`, acme's `xfideventwrite` parse: two characters, -/// two blank-separated decimals, a newline. Everything a full record carries -/// after that — the flag, the count, the text — is omitted on the way back in, -/// which is what acme(4) means by "with the flag, count, and text omitted". -/// -/// acme walks this with `strtoul`, pointer arithmetic and `goto Rescue`; here -/// the failure is `null` and the position stays in the struct, so the caller -/// can tell "ran out cleanly" from "stopped on garbage" by looking at `i`. -const EventReader = struct { - data: []const u8, - i: usize = 0, - - fn next(er: *EventReader) ?EventRecord { - if (er.i >= er.data.len) return null; - var i = er.i; - if (i + 2 > er.data.len) return null; - // acme stores the first character as `w->owner` (with a - // `/* disgusting */` beside it) so later records inherit whatever the - // writer claimed. Read and dropped here — see `writeEvent`. - i += 1; - const action = Action.fromChar(er.data[i]) orelse return null; - i += 1; - const q0 = scanNumber(er.data, &i) orelse return null; - const q1 = scanNumber(er.data, &i) orelse return null; - while (i < er.data.len and er.data[i] == ' ') i += 1; - if (i >= er.data.len or er.data[i] != '\n') return null; - er.i = i + 1; - return .{ .action = action, .q0 = q0, .q1 = q1 }; - } -}; - -fn scanNumber(data: []const u8, i: *usize) ?u32 { - while (i.* < data.len and data[i.*] == ' ') i.* += 1; - const s = i.*; - var n: u64 = 0; - while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1) - n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32)); - if (i.* == s) return null; - return @intCast(n); -} - -/// Writing a record back PERFORMS the action it names, "exactly as it would -/// have been if the event file had not been open" (acme(4)). This is the -/// documented remote-control door and the point of the whole suppression rule: -/// a script reads an `X` record, decides the text is not one of its own tag -/// commands, and hands it back for pardes to run. -/// -/// It is also, deliberately, arbitrary code execution — an `X` record is an -/// Exec — which is why the mount is 0700 under the user's runtime directory. -/// -/// NOTHING in the write applies unless all of it parses: acme validates each -/// record just before executing it and leaves the earlier ones done, which -/// makes a malformed batch half-applied and unrepeatable. -fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { - const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT); - const serial = pane0.serial; - { - const body = bodyOf(pane0); - const tag = tagOf(p, pane0); - var check: EventReader = .{ .data = req.data }; - while (check.next()) |r| { - switch (r.action) { - // acme accepts only `xXlL` on the way back in. A `D` or an `I` - // is a REPORT, not a request; writing one back would mean - // "pretend the user typed this", which nothing implements and - // acme's switch rejects with `Ebadevent`. - .body_look, .tag_look, .body_exec, .tag_exec => {}, - else => return Reply.fail(req.tag, E.INVAL), - } - // lower case is the tag, upper case the body — how a reader tells - // the two texts apart with no extra field - const n = if (r.action.onTag()) tag.len else body.len; - if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL); - } - if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL); - } - // acme(4): `F` is "actions through the window's other files", which is - // exactly what this is, and `handle` has already set it. acme takes the - // origin from the RECORD instead (`w->owner = *p++`, with a - // `/* disgusting */` beside it), so a writer can attribute its own action - // to the keyboard; the character is parsed here and dropped, because a - // record saying where it came from is worth nothing if the sender picks. - var run: EventReader = .{ .data = req.data }; - while (run.next()) |r| { - // an earlier action in this same write may have deleted the pane - const now = p.paneBySerial(serial) orelse break; - const pane = p.panes[now].?; - const whole = if (r.action.onTag()) tagOf(p, pane) else bodyOf(pane); - const lo = @min(@as(usize, r.q0), whole.len); - const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); - // the action can replace the very text it is reading from - const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; - switch (r.action) { - .body_exec, .tag_exec => _ = p.execute(now, text), - .body_look, .tag_look => p.lookAt(now, text), - else => unreachable, - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -// =========================================================================== -// +Errors — acme's `errorwin`. -// =========================================================================== - -/// acme(4): writing to `errors` "appends to the body of the dir/+Errors -/// window, where dir is the directory currently named in the tag. The window -/// is created if necessary, but not until text is actually written." -/// -/// One buffer per DIRECTORY, not per pane — which is why a search for an -/// existing one matches on the dirname and not on the writer. Answers HOW -/// MANY BYTES WERE TAKEN, or null for failure. -/// -/// The count matters because the append goes through `spliceBody`, which stops -/// at a whole-character boundary: the kernel splits a large `write(2)` at -/// `max_write` wherever it lands, so a multi-byte character straddling that -/// boundary must be reported short and retried by the writer's libc, exactly -/// as `body` and `data` do. Acknowledging the whole buffer would drop it. -fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { - if (text.len == 0) return 0; - const pane = p.panes[id] orelse return null; - const dir = dirOf(pane); - for (p.panes, 0..) |slot, i| { - const q = slot orelse continue; - const qf = fileOf(q) orelse continue; - const o = qf.output orelse continue; - if (std.meta.activeTag(o.from) != .errors) continue; - if (!std.mem.eql(u8, std.fs.path.dirname(qf.path) orelse "", dir)) continue; - return spliceBody(p, i, q, qf.content.len, qf.content.len, text); - } - const free = p.freeSlot() orelse return null; - const content = p.gpa.dupe(u8, text) catch return null; - const np = output_pane.open(p, free, dir, .errors, "", content) catch { - p.gpa.free(content); - return null; - }; - p.placeDoc(id, free, np); - return text.len; -} - -// =========================================================================== -// SIZES — what `stat` reports. -// =========================================================================== - -/// The whole `index`, measured. acme's `xfidindexread` walks every window to -/// size its buffer too; the tag of each is FORMATTED to be measured, into the -/// per-update scratch arena that is reset anyway, so this is bump allocation -/// rather than sixteen allocations a frame. -fn indexLen(p: *Pardes) u64 { - var n: u64 = 0; - for (p.panes) |slot| { - const pane = slot orelse continue; - n += ctl_fields + firstLine(tagOf(p, pane)).len + 1; - } - return n; -} - -/// A terminal's body has no length that is cheap AND honest — measuring it -/// means rendering the whole scrollback — so it reports zero and is served -/// with direct IO, exactly like `event` and `log`. -fn bodyLen(p: *Pardes, pane: *Pane) u64 { - _ = p; - return bodyOf(pane).len; -} - -fn tagLen(p: *Pardes, pane: *Pane) u64 { - return tagOf(p, pane).len; -} - -// =========================================================================== -// TESTS. -// -// The whole point of the split: every one of these drives `handle()` through -// the ordinary event queue with NO FUSE, NO mount, NO thread and no /dev/fuse -// anywhere. A filesystem whose semantics are a pure function of the core is a -// filesystem you can unit-test at the speed of a function call, and one whose -// blocking is a return value is one you can test without a scheduler. -// =========================================================================== - -const testing = std.testing; - -/// What a transport sees: the reply, and the bytes `fsPayload` resolved for it -/// inside the drain's borrow window. The two effects a filesystem operation -/// can additionally cause are captured too, because for `put` and for a write -/// to a terminal's body THE EFFECT IS THE ANSWER. -const Answer = struct { - reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO }, - bytes: []const u8 = "", - saved: bool = false, - pty_buf: [256]u8 = undefined, - pty_len: usize = 0, - /// `pty/ctl`'s three verbs are each ONE EFFECT and nothing else, so the - /// effect is the only thing a test can look at. - winsize: ?struct { cols: u16, rows: u16 } = null, - signal: ?pardes.PtySignal = null, - spawned: bool = false, - - fn pty(a: *const Answer) []const u8 { - return a.pty_buf[0..a.pty_len]; - } - - fn errno(a: Answer) u16 { - return if (a.reply.status == .err) a.reply.errno else 0; - } -}; - -/// One request in, one answer out. Effects are DRAINED but not performed: a -/// `put` must be observable as a `.save_file` without a test writing to the -/// real filesystem. -fn call(p: *Pardes, req: Req) Answer { - p.update(.{ .fs_req = req }); - var ans: Answer = .{}; - while (p.nextEffect()) |e| switch (e) { - .fs_reply => |r| { - ans.reply = r; - ans.bytes = p.fsPayload(r); - }, - .save_file, .save_text => ans.saved = true, - .write => |w| { - const b = w.bytes.slice(); - const n = @min(b.len, ans.pty_buf.len - ans.pty_len); - @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]); - ans.pty_len += n; - }, - .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows }, - .signal_pty => |s| ans.signal = s.sig, - .spawn => ans.spawned = true, - else => {}, - }; - return ans; -} - -fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { - return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size }); -} - -fn wr(p: *Pardes, node: u64, data: []const u8) Answer { - return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data }); -} - -fn rdir(p: *Pardes, node: u64, skip: u64) Answer { - return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 }); -} - -fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { - return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); -} - -/// A core with one FILE pane holding `text`, which is what most of acme's -/// window files are about. Slot 0, and its serial is the directory name. -fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - errdefer p.deinit(); - while (p.nextEffect()) |_| {} - _ = try p.hxOpenFileContent(text); - while (p.nextEffect()) |_| {} - return p; -} - -/// ...and a core whose slot 0 is a TERMINAL, which is what `pty/` is about. -/// `tty_only` opens exactly one shell pane and nothing else, so there is no -/// document anywhere and the geometry has already settled by the time the -/// startup effects are drained — a later `.resize_pty` in a test is therefore -/// one a verb caused. -fn withTerm(gpa: std.mem.Allocator) !*Pardes { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - errdefer p.deinit(); - while (p.nextEffect()) |_| {} - std.debug.assert(p.panes[0].?.isTerminal()); - return p; -} - -fn serialOf(p: *Pardes) u32 { - return p.panes[0].?.serial; -} - -const Dirent = struct { node: u64, dir: bool, name: []const u8 }; - -/// Decode the readdir staging format `src/fuse.zig` agreed to. -fn dirents(bytes: []const u8, out: []Dirent) []Dirent { - var n: usize = 0; - var i: usize = 0; - while (i + 10 <= bytes.len and n < out.len) { - const node = std.mem.readInt(u64, bytes[i..][0..8], .little); - const kind = bytes[i + 8]; - const len = bytes[i + 9]; - i += 10; - if (i + len > bytes.len) break; - out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] }; - i += len; - n += 1; - } - return out[0..n]; -} - -fn nameAt(list: []const Dirent, want: []const u8) ?Dirent { - for (list) |d| if (std.mem.eql(u8, d.name, want)) return d; - return null; -} - -test "readdir lists the root, a pane directory, and new/ without creating anything" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - var buf: [32]Dirent = undefined; - - const root = rdir(p, @intFromEnum(TopFile.root), 0); - try testing.expectEqual(Status.ok, root.reply.status); - const top = dirents(root.bytes, &buf); - try testing.expectEqual(@as(usize, 4), top.len); - try testing.expectEqualStrings("index", top[0].name); - try testing.expectEqualStrings("cons", top[1].name); - try testing.expectEqualStrings("new", top[2].name); - try testing.expect(top[2].dir and !top[0].dir); - var idbuf: [16]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&idbuf, "{d}", .{serial}), top[3].name); - try testing.expect(top[3].dir); - // the id a readdir reports is the id a getattr will report - try testing.expectEqual(Node.of(serial, .dir), top[3].node); - - // `off` skips entries, and past the end is EOF, not an error - const rest = rdir(p, @intFromEnum(TopFile.root), 3); - try testing.expectEqual(@as(usize, 1), dirents(rest.bytes, &buf).len); - const eof = rdir(p, @intFromEnum(TopFile.root), 99); - try testing.expectEqual(Status.ok, eof.reply.status); - try testing.expectEqual(@as(usize, 0), eof.bytes.len); - - const dir = rdir(p, Node.of(serial, .dir), 0); - const files = dirents(dir.bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); // dirtabw minus "." - try testing.expect(nameAt(files, "addr") != null); - try testing.expect(nameAt(files, "xdata") != null); - try testing.expect(nameAt(files, ".") == null); - try testing.expectEqual(Node.of(serial, .body), nameAt(files, "body").?.node); - - // acme(4) says accessing a file in `new` creates a window, so LISTING it - // must enumerate nothing at all: every name it could report is a name - // whose lookup creates a pane, and `ls -l` stats what a listing reported. - const before = p.next_serial; - const new = rdir(p, @intFromEnum(TopFile.new), 0); - try testing.expectEqual(Status.ok, new.reply.status); - try testing.expectEqual(@as(usize, 0), new.bytes.len); - try testing.expectEqual(before, p.next_serial); - - // a file is not a directory - try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); -} - -test "lookup resolves top files, pane serials and pane files" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - const root = @intFromEnum(TopFile.root); - - try testing.expectEqual(@as(u64, @intFromEnum(TopFile.index)), look_up(p, root, "index").reply.attr.node); - try testing.expect(look_up(p, root, "new").reply.attr.dir); - try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); - - var idbuf: [16]u8 = undefined; - const dir = look_up(p, root, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})); - try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); - try testing.expect(dir.reply.attr.dir); - // a serial that is not a live pane, and a serial that never existed - try testing.expectEqual(E.NOENT, look_up(p, root, "99999").errno()); - - const body = look_up(p, Node.of(serial, .dir), "body"); - try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); - // a lookup answers exactly what a getattr of the same node would - const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) }); - try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size); - try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno()); - try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); -} - -test "a lookup inside new/ creates a pane and resolves that pane's file" { - const gpa = testing.allocator; - const p = try withFile(gpa, "first\n"); - defer p.deinit(); - const before = serialOf(p); - - // a name that is not a pane file creates nothing - try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.new), "bogus").errno()); - try testing.expectEqual(before, p.next_serial); - - const a = look_up(p, @intFromEnum(TopFile.new), "body"); - try testing.expectEqual(Status.ok, a.reply.status); - const made: Node = @bitCast(a.reply.attr.node); - try testing.expect(made.serial != before); - try testing.expectEqual(@intFromEnum(PaneFile.body), made.file); - - // ...and it is a real pane: `echo hi > new/body` leaves a pane holding hi - _ = wr(p, a.reply.attr.node, "hi"); - const id = p.paneBySerial(@intCast(made.serial)).?; - try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); -} - -test "index prints winctlprint's five fields then the tag" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\nthere\n"); - defer p.deinit(); - const pane = p.panes[0].?; - - const a = rd(p, @intFromEnum(TopFile.index), 0, 4096); - try testing.expectEqual(Status.ok, a.reply.status); - var got: [512]u8 = undefined; - @memcpy(got[0..a.bytes.len], a.bytes); - const line = got[0..a.bytes.len]; - - const tag = tagOf(p, pane); - var want: std.ArrayList(u8) = .empty; - defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s}\n", .{ - pane.serial, tag.len, @as(usize, "hello\nthere\n".len), 0, 0, firstLine(tag), - }); - try testing.expectEqualStrings(want.items, line); - // acme(4): "at character position 5x12 starts the name of the window" - try testing.expectEqual(@as(usize, 60), std.mem.indexOf(u8, line, firstLine(tag)).?); - - // seekable: a script may pread the middle of it - const mid = rd(p, @intFromEnum(TopFile.index), 60, 5); - try testing.expectEqualStrings(firstLine(tag)[0..5], mid.bytes); - - // ...and a dirty pane says so in the fifth field - pane.file.?.saved_revision = pane.file.?.revision -% 1; - const dirty = rd(p, @intFromEnum(TopFile.index), 48, 12); - try testing.expectEqualStrings(" 1 ", dirty.bytes); -} - -test "ctl read is index's five fields plus width in cells, font and tab width" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const pane = p.panes[0].?; - - const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); - try testing.expectEqual(Status.ok, a.reply.status); - var want: std.ArrayList(u8) = .empty; - defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{ - pane.serial, tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, - }); - try testing.expectEqualStrings(want.items, a.bytes); - - // plan9 %q: a name with a space in it becomes one shell word - var quoted: std.ArrayList(u8) = .empty; - defer quoted.deinit(gpa); - stageQuoted("ed, gpa, "DejaVu Sans Mono"); - try testing.expectEqualStrings("'DejaVu Sans Mono'", quoted.items); - quoted.clearRetainingCapacity(); - stageQuoted("ed, gpa, "it's"); - try testing.expectEqualStrings("'it''s'", quoted.items); -} - -test "body reads at any offset and writes append" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const body = Node.of(serial, .body); - - try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes); - try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes); - try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes); - try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes); - // zero copy: the answer points INTO the pane, it is not a staged copy - try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr); - - // acme(4): "Text written to body is always appended; the file offset is - // ignored" — so a write at offset 0 still lands at the end. - const w = call(p, .{ .tag = 5, .op = .write, .node = body, .off = 0, .data = "three\n" }); - try testing.expectEqual(@as(u32, 6), w.reply.written); - try testing.expectEqualStrings("one\ntwo\nthree\n", p.panes[0].?.file.?.content); - - // a write cut mid-character is SHORT, never split - const short = wr(p, body, "a\xC3"); - try testing.expectEqual(@as(u32, 1), short.reply.written); - try testing.expectEqualStrings("one\ntwo\nthree\na", p.panes[0].?.file.?.content); -} - -test "a body write to a terminal pane types at its shell" { - const gpa = testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.isTerminal()); - - // `win`'s transcript semantics: the only way into a program's transcript - // is to type at it, so a body write becomes a pty write. - const a = wr(p, Node.of(pane.serial, .body), "ls -l\r"); - try testing.expectEqual(@as(u32, 6), a.reply.written); - try testing.expectEqualStrings("ls -l\r", a.pty()); - - // and a body READ renders the scrollback rather than lending a buffer - const r = rd(p, Node.of(pane.serial, .body), 0, 64); - try testing.expectEqual(Status.ok, r.reply.status); -} - -test "tag reads the whole tag and writes append to the editable tail" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const pane = p.panes[0].?; - const node = Node.of(pane.serial, .tag); - - const whole = rd(p, node, 0, 4096); - try testing.expect(std.mem.startsWith(u8, whole.bytes, "/hxcase.txt")); - try testing.expect(std.mem.indexOf(u8, whole.bytes, "Del") != null); - - const before = rd(p, node, 0, 4096).bytes.len; - const w = wr(p, node, " Mine"); - try testing.expectEqual(@as(u32, 5), w.reply.written); - try testing.expect(std.mem.endsWith(u8, pane.tag_tail[0..pane.tag_tail_len], " Mine")); - const after = rd(p, node, 0, 4096); - try testing.expectEqual(before + 5, after.bytes.len); - try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine")); - - // the tail is one bounded line; with no room left the file is FULL - pane.tag_tail_len = pane.tag_tail.len; - try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno()); -} - -test "the address language, form by form" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\nthree\n"); // 14 bytes, three lines - defer p.deinit(); - const serial = serialOf(p); - const addr = Node.of(serial, .addr); - - const Case = struct { expr: []const u8, q0: u32, q1: u32 }; - for ([_]Case{ - .{ .expr = "#0", .q0 = 0, .q1 = 0 }, - .{ .expr = "#5", .q0 = 5, .q1 = 5 }, - .{ .expr = "0", .q0 = 0, .q1 = 0 }, - .{ .expr = "1", .q0 = 0, .q1 = 4 }, - .{ .expr = "2", .q0 = 4, .q1 = 8 }, - .{ .expr = "$", .q0 = 14, .q1 = 14 }, - .{ .expr = ",", .q0 = 0, .q1 = 14 }, - .{ .expr = "1,2", .q0 = 0, .q1 = 8 }, - .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 }, - .{ .expr = "2+1", .q0 = 8, .q1 = 14 }, - .{ .expr = "$-1", .q0 = 8, .q1 = 14 }, - .{ .expr = "/two/", .q0 = 4, .q1 = 7 }, - .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 }, - // a trailing newline is what a shell redirect leaves behind - .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, - }) |c| { - // every case starts from a known address, so `.` and `+`/`-` are - // measured against the same place each time - _ = wr(p, addr, "#0"); - const w = wr(p, addr, c.expr); - try testing.expectEqual(Status.ok, w.reply.status); - const got = rd(p, addr, 0, 64); - var want: [32]u8 = undefined; - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }), - got.bytes, - ); - } - - // `.` is the CURRENT ADDRESS (acme passes w->addr as `ar`), not the - // selection: set it, then ask for it back. - _ = wr(p, addr, "1"); - _ = wr(p, addr, "."); - try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1); - - // `?re?` searches BACKWARD from the start of the running range and takes - // the LAST match before it — acme's `rxbexecute`. - _ = wr(p, addr, "$"); - _ = wr(p, addr, "?o?"); - try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" - try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); - - // limit=addr confines a forward search - _ = wr(p, addr, "1"); - _ = wr(p, Node.of(serial, .ctl), "limit=addr\n"); - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, "/three/").errno()); - _ = wr(p, Node.of(serial, .ctl), "clean\n"); // any ctl write; limit stays - // ...and opening ctl clears it again (acme(4)) - _ = call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); - try testing.expect(p.fs.panes[0].limit == null); - _ = wr(p, addr, "#0"); - try testing.expectEqual(Status.ok, wr(p, addr, "/three/").reply.status); - - // refusals - for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, bad).errno()); - } - - // acme recurses once per `,` with no bound at all, which a script turns - // into a stack overflow with one write(2). Refused, not crashed. - const nested = "," ** 4096; - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, nested).errno()); -} - -test "data and xdata read from addr, move it, and write through it" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const addr = Node.of(serial, .addr); - const data = Node.of(serial, .data); - const xdata = Node.of(serial, .xdata); - - _ = wr(p, addr, "#0"); - try testing.expectEqualStrings("one", rd(p, data, 0, 3).bytes); - // ...and the address is now the null string after what was returned - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); - - // xdata stops at the END of the address where data would run on - _ = wr(p, addr, "1"); - try testing.expectEqualStrings("one\n", rd(p, xdata, 0, 100).bytes); - _ = wr(p, addr, "1"); - try testing.expectEqualStrings("one\ntwo\n", rd(p, data, 0, 100).bytes); - - // a write REPLACES the addressed text and leaves the address after it - _ = wr(p, addr, "1"); - const w = wr(p, data, "ONE\n"); - try testing.expectEqual(@as(u32, 4), w.reply.written); - try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); - try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q0); -} - -test "data never splits a grapheme, in either direction" { - const gpa = testing.allocator; - const p = try withFile(gpa, "\u{00e9}x\n"); // é is two bytes - defer p.deinit(); - const serial = serialOf(p); - _ = wr(p, Node.of(serial, .addr), "#0"); - // one byte is not enough for the first character: acme's `if(m == 0) break` - try testing.expectEqualStrings("", rd(p, Node.of(serial, .data), 0, 1).bytes); - _ = wr(p, Node.of(serial, .addr), "#0"); - try testing.expectEqualStrings("\u{00e9}", rd(p, Node.of(serial, .data), 0, 2).bytes); - - // and a write ending mid-character is short rather than corrupting - _ = wr(p, Node.of(serial, .addr), "#0"); - try testing.expectEqual(@as(u32, 1), wr(p, Node.of(serial, .data), "a\xC3").reply.written); -} - -test "rdsel reads the selection and wrsel replaces it" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - - _ = wr(p, Node.of(serial, .addr), "#0,#3"); - try testing.expectEqual(Status.ok, wr(p, ctl, "dot=addr\n").reply.status); - try testing.expectEqualStrings("one", rd(p, Node.of(serial, .rdsel), 0, 100).bytes); - - // ...and the round trip back out is the identity, not a range that creeps - _ = wr(p, ctl, "addr=dot\n"); - try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); - - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .wrsel), "ONE").reply.status); - try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); - // a second write appends after the first, acme's `wrselrange` - _ = wr(p, Node.of(serial, .wrsel), "!"); - try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content); -} - -test "every ctl verb, and every refusal" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - const pane = p.panes[0].?; - const pf = &p.fs.panes[0]; - - // several verbs in one write, which is what the man page promises - try testing.expectEqual(Status.ok, wr(p, ctl, "nomark\nnoscroll\ndirty\n").reply.status); - try testing.expect(pf.nomark and pf.noscroll and dirtyOf(pane)); - try testing.expectEqual(Status.ok, wr(p, ctl, "mark\nscroll\nclean\n").reply.status); - try testing.expect(!pf.nomark and !pf.noscroll and !dirtyOf(pane)); - - _ = wr(p, ctl, "cleartag\n"); - try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); - - _ = wr(p, Node.of(serial, .addr), "2"); - _ = wr(p, ctl, "limit=addr\n"); - try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); - _ = wr(p, ctl, "dot=addr\nshow\n"); - try testing.expectEqual(@as(i32, 1), pane.cur_row); - - try testing.expectEqual(Status.ok, wr(p, ctl, "name /tmp/renamed.txt\n").reply.status); - try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); - // acme rejects a name with any character <= ' ' in it - try testing.expectEqual(E.INVAL, wr(p, ctl, "name two words\n").errno()); - try testing.expectEqual(E.INVAL, wr(p, ctl, "name\n").errno()); - try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); - - // `put` is acme's Put, which is pardes's Save - try testing.expect(wr(p, ctl, "put\n").saved); - - // REFUSED, each for a reason that is not "unimplemented" — see - // `refused_verbs`. Silently accepting these is the worse failure. - for ([_][]const u8{ - "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", - }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl, bad).errno()); - - // ATOMIC, which acme is not: an unknown verb aborts the WHOLE write. - try testing.expect(!dirtyOf(pane)); - try testing.expectEqual(E.INVAL, wr(p, ctl, "dirty\nbogus\n").errno()); - try testing.expect(!dirtyOf(pane)); -} - -test "ctl get reloads the pane from disk and del honours a dirty body" { - const gpa = testing.allocator; - var tmp = testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/note.txt", .{tmp.sub_path}); - - const p = try withFile(gpa, "in memory\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - const pane = p.panes[0].?; - - var name: [std.fs.max_path_bytes + 8]u8 = undefined; - _ = wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}\n", .{path})); - try testing.expectEqual(Status.ok, wr(p, ctl, "get\n").reply.status); - try testing.expectEqualStrings("from disk\n", pane.file.?.content); - // Get leaves the pane clean and the previous text one Undo away - try testing.expect(!dirtyOf(pane)); - try testing.expect(pane.file.?.undo_len > 0); - - // acme: `del` is "delete, but check dirty"; `delete` is "delete for sure" - _ = wr(p, ctl, "dirty\n"); - try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno()); - try testing.expect(p.paneBySerial(serial) != null); - // ...and a second pane so the last one closing does not quit the editor - _ = look_up(p, @intFromEnum(TopFile.new), "body"); - try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status); - try testing.expect(p.paneBySerial(serial) == null); -} - -test "errors and cons append to one +Errors buffer per directory" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - - const live = for (p.panes) |slot| { - if (slot) |q| if (q.file) |f| if (f.output) |o| if (std.meta.activeTag(o.from) == .errors) break q; - } else null; - try testing.expect(live == null); // "not until text is actually written" - - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status); - _ = wr(p, @intFromEnum(TopFile.cons), "again\n"); - - var found: usize = 0; - for (p.panes) |slot| { - const q = slot orelse continue; - const f = q.file orelse continue; - const o = f.output orelse continue; - if (std.meta.activeTag(o.from) != .errors) continue; - found += 1; - try testing.expectEqualStrings("boom\nagain\n", f.content); - try testing.expectEqualStrings("/+Errors", f.path); - } - try testing.expectEqual(@as(usize, 1), found); -} - -test "setattr truncation empties the body and answers fresh attributes" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - - const a = call(p, .{ .tag = 7, .op = .setattr, .node = Node.of(serial, .body), .truncate = true }); - try testing.expectEqual(Status.ok, a.reply.status); - try testing.expectEqual(@as(u64, 0), a.reply.attr.size); - try testing.expectEqualStrings("", p.panes[0].?.file.?.content); - - // `> body` then a write is the shell's way of REPLACING a pane's text - _ = wr(p, Node.of(serial, .body), "new text\n"); - try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content); - - // a setattr that sets no size changes nothing - const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) }); - try testing.expectEqual(@as(u64, 9), noop.reply.attr.size); -} - -test "event records are acme's bytes, one per read, and .again when empty" { - const gpa = testing.allocator; - const p = try withFile(gpa, "Msg fs-ran\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - - // nothing is recorded while nobody is listening - _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg "); - try testing.expect(p.fs.panes[0].events.empty()); - - const h = call(p, .{ .tag = 10, .op = .open, .node = event }); - try testing.expect(h.reply.handle != 0); - try testing.expectEqual(@as(u16, 1), p.fs.listeners); - - // an empty queue is `.again`: nothing consumed, ask me later. NEVER an - // error, and never a loop. - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); - - p.fs.origin = 'M'; - _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell"); - _ = noteAction(p, 0, .body_delete, 0, 3, 0, ""); - // `%c%c%d %d %d %d %s\n`, wind.c's winevent with the owner char in front - try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes); - try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes); - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); - - // one record per read: a read too small to hold one is refused rather - // than answered with half a record the reader cannot resynchronise from - _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); - try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno()); - try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes); - - // text of 256 bytes or more is elided; the reader fetches it from `data` - const big = "z" ** max_record_text; - _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); - try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes); - - _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); -} - -/// Drain a queue into `store` and return the records. Reading is destructive -/// and a `.staged` answer is only valid until the next request, so each record -/// is copied out as it arrives. -fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 { - var used: usize = 0; - var n: usize = 0; - while (n < out.len) { - const a = rd(p, node, 0, 4096); - if (a.reply.status != .ok) break; - @memcpy(store[used..][0..a.bytes.len], a.bytes); - out[n] = store[used..][0..a.bytes.len]; - used += a.bytes.len; - n += 1; - } - return out[0..n]; -} - -test "a write through the filesystem is reported once, attributed to the file it came through" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - _ = call(p, .{ .tag = 40, .op = .open, .node = event }); - var store: [4096]u8 = undefined; - var slots: [16][]const u8 = undefined; - _ = drainEvents(p, event, &store, &slots); - - // A body write is acme's `E`: "writes to the body or tag file". ONE pair - // per write, because the diff lives in `file_pane.setContent` and a write - // is one content swap — that is the contract with the core's hook, and - // emitting records from the handler as well is what it forbids. - _ = wr(p, Node.of(serial, .body), "three\n"); - const body_recs = drainEvents(p, event, &store, &slots); - try testing.expect(body_recs.len >= 1); - // ...and the record's TEXT here contains a newline of its own, which is - // exactly why `Queue` frames records by length instead of by line - try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); - // the write also made the pane dirty, so its TAG changed — and acme - // attributes that to the write too (`winsettag` runs inside the same - // `winlock(w, 'E')`), which is why the origin is set for the whole - // request and not just for the mutation. - for (body_recs[1..]) |r| { - try testing.expectEqual(@as(u8, 'E'), r[0]); - try testing.expect(Action.fromChar(r[1]).?.onTag()); - } - - // A `data` write is acme's `F`: "actions through the window's other - // files" — and a replacement is a delete then an insert, acme's order, - // with no text on the delete. - _ = wr(p, Node.of(serial, .addr), "1"); - _ = wr(p, Node.of(serial, .data), "ONE\n"); - const data_recs = drainEvents(p, event, &store, &slots); - try testing.expectEqual(@as(usize, 2), data_recs.len); - try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]); - try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]); - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); -} - -test "two event readers each count once, and the second closing leaves the first" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const event = Node.of(serialOf(p), .event); - - _ = call(p, .{ .tag = 12, .op = .open, .node = event }); - _ = call(p, .{ .tag = 13, .op = .open, .node = event }); - try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers); - try testing.expectEqual(@as(u16, 2), p.fs.listeners); - - // A release names the NODE, not a handle: FUSE carries the nodeid on every - // request, so there is no fid table to look one up in, and one release - // answers for one open. - _ = call(p, .{ .tag = 14, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers); - try testing.expect(p.fs.scripted(0)); // the pane is STILL script-driven - - // a release with nothing left to release changes nothing and is not an - // error, and neither is one naming a node that never counted - _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) }); - try testing.expectEqual(@as(u16, 1), p.fs.listeners); - - _ = call(p, .{ .tag = 16, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - try testing.expect(!p.fs.scripted(0)); - _ = call(p, .{ .tag = 17, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); -} - -test "a pane deleted while its event file is open leaves no suppression behind" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - // a second pane, so deleting the first does not quit the editor - _ = look_up(p, @intFromEnum(TopFile.new), "body"); - - const a = call(p, .{ .tag = 18, .op = .open, .node = event }); - const b = call(p, .{ .tag = 19, .op = .open, .node = event }); - try testing.expectEqual(@as(u16, 2), p.fs.listeners); - - _ = wr(p, Node.of(serial, .ctl), "delete\n"); - try testing.expect(p.paneBySerial(serial) == null); - // the core's `State.forget` took BOTH readers out with the pane - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - - // ...and the two late releases must not underflow it back to 65535, which - // would suppress every button action in the editor forever - _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); - _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - - // every operation on the dead pane is ENOENT — acme's Edel - try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); - try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); - try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); -} - -test "writing an event record back performs the action it names" { - const gpa = testing.allocator; - const p = try withFile(gpa, "Msg fs-ran\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - const pane = p.panes[0].?; - - // an `X` record over the body text `Msg fs-ran` is an Exec of it - const w = wr(p, event, "FX0 10\n"); - try testing.expectEqual(Status.ok, w.reply.status); - try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); - - // several records in one write - pane.msg_len = 0; - try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); - try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); - - // ...and nothing applies when any of it is malformed: acme's Ebadevent - pane.msg_len = 0; - for ([_][]const u8{ - "FX0 10\nFQ0 1\n", // unknown type character - "FX0 999\n", // out of range - "FX0 10", // no newline - "FX5 1\n", // q0 > q1 - "FD0 3\n", // a report, not a request - "F\n", - }) |bad| { - try testing.expectEqual(E.INVAL, wr(p, event, bad).errno()); - try testing.expectEqual(@as(usize, 0), pane.msg_len); - } - - // The action is attributed to the FILESYSTEM (`F`), never to whatever the - // writer put in the record's origin character — acme copies that byte - // into `w->owner` and lets a script claim its Exec came from the - // keyboard. - _ = call(p, .{ .tag = 23, .op = .open, .node = event }); - p.fs.origin = 'K'; - _ = wr(p, event, "KX0 10\n"); - try testing.expectEqual(@as(u8, 'F'), p.fs.origin); -} - -test "a pane that is not a terminal has no pty/ at all" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - const dir = Node.of(serial, .dir); - - // ABSENT, not present-and-refusing: `-d $PARDES_FS/<id>/pty` is how a - // script asks whether a pane is a terminal. - try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno()); - try testing.expectEqual(E.NOENT, call(p, .{ - .tag = 1, - .op = .getattr, - .node = Node.of(serial, .pty), - }).errno()); - try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno()); - try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno()); - // ...and an OPEN too, so the reader count that gates the raw queue can - // never be armed on a pane that has no pty to produce bytes - try testing.expectEqual(E.NOENT, call(p, .{ - .tag = 2, - .op = .open, - .node = Node.of(serial, .pty_data), - }).errno()); - try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); - - // ...and the listing is byte for byte the ten entries it always was - var buf: [32]Dirent = undefined; - const files = dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); - try testing.expect(nameAt(files, "pty") == null); - - // the enum's spelling is not a name in the tree: `pty_ctl` is how the flat - // enum spells `pty/ctl`, and neither directory answers to it - try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); - try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); - - // `new/` makes a scratch, which can never be a terminal, so naming a pty - // file there creates nothing at all - const before = p.next_serial; - try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.new), "pty").errno()); - try testing.expectEqual(before, p.next_serial); -} - -test "a terminal pane's pty/ holds exactly ctl, status and data" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const serial = serialOf(p); - const dir = Node.of(serial, .dir); - - const pty = look_up(p, dir, "pty"); - try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node); - try testing.expect(pty.reply.attr.dir); - try testing.expectEqual(@as(u16, 0o500), pty.reply.attr.mode); - - var buf: [32]Dirent = undefined; - const files = dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 11), files.len); // the ten, plus pty - try testing.expect(nameAt(files, "pty").?.dir); - - const inside = dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); - try testing.expectEqual(@as(usize, 3), inside.len); - try testing.expectEqualStrings("ctl", inside[0].name); - try testing.expectEqualStrings("status", inside[1].name); - try testing.expectEqualStrings("data", inside[2].name); - for (inside) |d| try testing.expect(!d.dir); - // the ids a listing reports are the ids a lookup resolves - try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); - - // ...and the two namespaces do not leak into each other - const ctl = look_up(p, Node.of(serial, .pty), "ctl"); - try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node); - try testing.expectEqual(@as(u16, 0o200), ctl.reply.attr.mode); - try testing.expectEqual(@as(u16, 0o400), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno()); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno()); - - // a file is not a directory, on either side of the slash - try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno()); - try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno()); - // and the directory itself is not read(2)able, nor is a write-only file - try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno()); - try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno()); - try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno()); -} - -test "every pty/ctl verb, and every refusal" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const ctl = Node.of(serialOf(p), .pty_ctl); - - // winsize reaches the effect queue, and ONLY the pty: the grid belongs to - // the layout, so the pane's own cols/rows are untouched. - const pane = p.panes[0].?; - const cols = pane.cols; - const rows = pane.rows; - const ws = wr(p, ctl, "winsize 132 44\n"); - try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written); - try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols); - try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); - try testing.expectEqual(cols, pane.cols); - try testing.expectEqual(rows, pane.rows); - - // all five signal names, and no others - for ([_]struct { line: []const u8, want: pardes.PtySignal }{ - .{ .line = "sig INT", .want = .int }, - .{ .line = "sig TERM", .want = .term }, - .{ .line = "sig HUP", .want = .hup }, - .{ .line = "sig QUIT", .want = .quit }, - .{ .line = "sig KILL", .want = .kill }, - }) |c| { - const a = wr(p, ctl, c.line); - try testing.expectEqual(Status.ok, a.reply.status); - try testing.expectEqual(c.want, a.signal.?); - } - - // exec respawns the shell: the same effect `newShell` emits - const ex = wr(p, ctl, "exec\n"); - try testing.expectEqual(Status.ok, ex.reply.status); - try testing.expect(ex.spawned); - - // several verbs in one write, no trailing newline needed - const both = wr(p, ctl, "winsize 100 30\nsig TERM"); - try testing.expectEqual(@as(u16, 100), both.winsize.?.cols); - try testing.expectEqual(pardes.PtySignal.term, both.signal.?); - - // ...and EVERY malformed line refuses the WHOLE batch, so the good verb - // beside it never reached the queue. Two passes, one applied. - for ([_][]const u8{ - "winsize", // no arguments - "winsize 80", // one argument - "winsize 80 24 extra", // three - "winsize 0 24", // zero is "unknown", never a width - "winsize 80 0", - "winsize -1 24", // not a decimal - "winsize 999999 24", // wider than a u16 - "sig", // no name - "sig INT TERM", // two - "sig SIGINT", // the prefix `kill` dropped in 1988 - "sig int", // lower case - "sig 9", // a number is one platform's number - "sig USR1", // a real signal, deliberately not offered - "exec /bin/sh", // the effect carries no argv; refused, never ignored - "raw", // the draft's TCSETS line, which the core cannot answer - "cooked", - "winsize 80 24\nbogus", // a good verb beside a bad one - "bogus\nwinsize 80 24", - "name x", // a `ctl` verb; the two files share no vocabulary - "del", - }) |bad| { - const a = wr(p, ctl, bad); - try testing.expectEqual(E.INVAL, a.errno()); - try testing.expect(a.winsize == null); - try testing.expect(a.signal == null); - try testing.expect(!a.spawned); - } - - // blank lines and surrounding space are not verbs and not errors - const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); - try testing.expectEqual(Status.ok, spaced.reply.status); - try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); - // an empty write is a write of nothing - try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); -} - -/// A host that answers `pull_tty_taken` and nothing else, so `pty/status`'s -/// third field can be tested with no pty anywhere. The same shape -/// `pardes.zig`'s own `FakeTtyQuery` has, spelled again here because that one -/// is private to its own tests. -const FakeTty = struct { - taken: bool, - - const vtable: pardes.Host.VTable = .{ .pull_tty_taken = answer }; - - fn answer(ctx: ?*anyopaque, pane: u8) bool { - _ = pane; - const f: *FakeTty = @ptrCast(@alignCast(ctx.?)); - return f.taken; - } -}; - -test "pty/status reports the grid and who holds the tty" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const pane = p.panes[0].?; - const status = Node.of(pane.serial, .pty_status); - - const a = rd(p, status, 0, 256); - try testing.expectEqual(Status.ok, a.reply.status); - var want: [64]u8 = undefined; - const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); - try testing.expectEqualStrings(whole, a.bytes); - // three `%11d ` fields, like `ctl` and `index`, and seekable like both. - // The expectation is compared against `want` and not against `a.bytes`, - // which the NEXT request's staging invalidates — the borrow window this - // whole module is built on. - try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); - try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); - - // the third field is `pull_tty_taken`, the probe the core already has - var probe: FakeTty = .{ .taken = true }; - p.host = .{ .ctx = &probe, .vtable = &FakeTty.vtable }; - const held = rd(p, status, 0, 256); - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), - held.bytes, - ); -} - -test "pty/data writes at the shell and reads the raw stream" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const serial = serialOf(p); - const data = Node.of(serial, .pty_data); - - // WRITE is a pty write, exactly as a body write to a terminal is, and the - // offset is ignored because a stream has none - const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" }); - try testing.expectEqual(@as(u32, 6), w.reply.written); - try testing.expectEqualStrings("ls -l\r", w.pty()); - // short at a character boundary, never split, never zero for real bytes - try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); - try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); - - // READ blocks — `.again`, nothing consumed — while there is nothing there - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // THE READER COUNT IS THE GATE: output arriving at a pane nobody is - // reading is not recorded, so the queue stays empty and the pane pays - // nothing for a filesystem it is not using. - p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - _ = call(p, .{ .tag = 5, .op = .open, .node = data }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); - // ...and it is NOT the event-suppression gate: reading a terminal's output - // is not claiming the pane's buttons. - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - try testing.expect(!p.fs.scripted(0)); - - p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // UNFRAMED: a read smaller than one arrival is served and the remainder - // kept, because raw pty bytes have no records to split down the middle. - // `event` refuses exactly this read; that is the difference, on purpose. - p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes); - try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes); - // ...and a read SPANS arrivals, which one read(2) on the pty would too - p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); - - // the LAST reader leaving gives the memory back and drops what is stale - p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } }); - while (p.nextEffect()) |_| {} - _ = call(p, .{ .tag = 6, .op = .release, .node = data }); - try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); - try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // two readers: the second closing leaves the first still recording - _ = call(p, .{ .tag = 7, .op = .open, .node = data }); - _ = call(p, .{ .tag = 8, .op = .open, .node = data }); - _ = call(p, .{ .tag = 9, .op = .release, .node = data }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); - p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); -} - -test "the pty queue drops the oldest at its cap" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const data = Node.of(serialOf(p), .pty_data); - _ = call(p, .{ .tag = 5, .op = .open, .node = data }); - - // A script that opens the file and stops reading must BOUND the editor, - // not grow it. The oldest arrivals go; a reader that fell this far behind - // has lost the thread anyway and can re-read `body` to resynchronise. - const oldest: [4096]u8 = @splat('A'); - const rest: [4096]u8 = @splat('B'); - notePtyOutput(p, 0, &oldest); - for (0..queue_cap / rest.len + 4) |_| notePtyOutput(p, 0, &rest); - // LIVE bytes, not the buffer: `Queue` pops by moving `head` and reclaims - // the space lazily (`compact`), so the allocation trails the contents by - // design and the cap is a bound on what is still owed to a reader. - const q = &p.fs.panes[0].pty_out; - try testing.expect(q.buf.items.len - q.head <= queue_cap); - - var seen: usize = 0; - while (true) { - const a = rd(p, data, 0, 1 << 16); - if (a.reply.status == .again) break; - try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null); - if (a.bytes.len == 0) break; - seen += a.bytes.len; - } - try testing.expect(seen > 0 and seen <= queue_cap); -} |
