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