//! 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 nothing. pub const PaneFile = enum(u4) { dir = 0, addr, body, ctl, data, errors, event, tag, xdata, rdsel, wrsel, /// Every name IS the variant's name; only the directory itself is spelled /// differently, because `.` is not an identifier. pub fn name(f: PaneFile) []const u8 { return if (f == .dir) "." else @tagName(f); } /// acme's dirtabw modes: 0400 read, 0200 write, 0600 both. pub fn mode(f: PaneFile) u16 { return switch (f) { .dir => 0o500, .errors, .wrsel => 0o200, .rdsel => 0o400, else => 0o600, }; } }; /// 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; } } 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; } }; /// 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, pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; fn deinit(pf: *PaneFs, gpa: std.mem.Allocator) void { pf.events.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; } // ============================================================================ // 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; return .{ .ok = .{ .node = Node.of(t.serial, t.file), .dir = t.file == .dir, .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), .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 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. // =========================================================================== /// The files inside a pane's directory, by name. `.dir` is the directory /// itself and is never a name to resolve. /// A name inside a pane's directory. `.` and `..` are the kernel's business, /// never ours, and the directory variant is not nameable — so a hit on the /// variant names is the whole lookup. fn paneFileNamed(name: []const u8) ?PaneFile { const f = std.meta.stringToEnum(PaneFile, name) orelse return null; return if (f == .dir) null else f; } 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); 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: { if (t.file != .dir) return Reply.fail(req.tag, E.NOTDIR); _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const f = paneFileNamed(name) 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 and for `new/`. `serial == 0` is /// `new/`: there is no pane yet — the LOOKUP is what creates one — so there is /// no id to report, and the transport substitutes one. fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { inline for (comptime std.enums.values(PaneFile)) |f| { if (f != .dir) { 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| { if (t.file != .dir) return Reply.fail(req.tag, E.NOTDIR); _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); stagePaneFiles(p, out, t.serial, &skip); }, } // 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 event readers. /// /// 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]; 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; }, 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) 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 (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]; 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), .dir, .errors, .wrsel => 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) } }; } // =========================================================================== // 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 `/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].?; 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), .dir, .rdsel => 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; } // =========================================================================== // 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, 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; }, 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; } 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); }