diff options
Diffstat (limited to 'src/ninep/tree.zig')
| -rw-r--r-- | src/ninep/tree.zig | 850 |
1 files changed, 850 insertions, 0 deletions
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig new file mode 100644 index 00000000..f716bf5f --- /dev/null +++ b/src/ninep/tree.zig @@ -0,0 +1,850 @@ +//! The control tree served over 9P: its nodes, lookup, readdir and the +//! dispatch that answers the engine in src/9p.zig. The backend contract +//! (Req, Reply, Op, Status, E) lives here; fs.zig keeps host access and mounts. +//! +//! /README /index /ctl /new /log /screen /listeners +//! /pane/<serial>/{name,body,tag,ctl,addr,data,xdata,sel,errors,event,pty/} +//! /os/... the host filesystem /src/... embedded sources (opt-in) +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const fs = @import("../fs.zig"); +pub const events = @import("events.zig"); +pub const pane = @import("pane.zig"); +pub const addr = @import("addr.zig"); +pub const ctl = @import("ctl.zig"); +pub const pty = @import("pty.zig"); +pub const sources = @import("sources.zig"); +pub const screen = @import("screen.zig"); + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const MAX_PANES = pardes.MAX_PANES; + +pub const name_capacity: usize = 255; + +// ---- the backend contract ---- + +pub const Op = enum(u8) { + lookup, + getattr, + setattr, + open, + read, + write, + release, + readdir, +}; + +pub const Status = enum(u8) { + ok, + again, + err, +}; + +pub const Req = struct { + tag: u64, + op: Op, + node: u64, + handle: u32 = 0, + off: u64 = 0, + size: u32 = 0, + data: []const u8 = &.{}, + truncate: bool = false, + + pub fn changesPane(req: Req) bool { + return switch (req.op) { + .write, .setattr => true, + .open => req.node == @intFromEnum(TopFile.new), + .lookup, .getattr, .read, .release, .readdir => false, + }; + } +}; + +pub const Reply = struct { + tag: u64, + status: Status = .ok, + errno: u16 = 0, + attr: Attr = .{}, + handle: u32 = 0, + payload: Payload = .none, + written: u32 = 0, + + pub const Attr = struct { + name: []const u8 = "", + node: u64 = 0, + dir: bool = false, + size: u64 = 0, + mode: u16 = 0o644, + mtime: u32 = 0, + }; + + pub const Payload = union(enum) { + none, + staged: u32, + 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 }; + } +}; + +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; + pub const NOSPC: u16 = 28; + pub const NOSYS: u16 = 38; +}; + +pub const out_reserve = 4 * 1024; + +// ---- nodes ---- + +pub const TopFile = enum(u4) { + root = 1, + README, + index, + ctl, + new, + log, + screen, + listeners, + pane, + + pub fn fileName(f: TopFile) []const u8 { + return if (f == .root) "/" else @tagName(f); + } + + pub fn mode(f: TopFile) u16 { + return switch (f) { + .root, .pane => 0o755, + .ctl => 0o666, + .README, .index, .new, .log, .screen, .listeners => 0o444, + }; + } + + pub fn dir(f: TopFile) bool { + return f == .root or f == .pane; + } +}; + +pub const root: u64 = @intFromEnum(TopFile.root); + +pub const PaneFile = enum(u4) { + dir = 0, + name, + body, + tag, + ctl, + addr, + data, + xdata, + sel, + errors, + event, + pty, + pty_ctl, + pty_status, + pty_data, + + pub fn fileName(f: PaneFile) []const u8 { + return switch (f) { + .dir => ".", + .pty_ctl => "ctl", + .pty_status => "status", + .pty_data => "data", + else => @tagName(f), + }; + } + + pub fn mode(f: PaneFile) u16 { + return switch (f) { + .dir, .pty => 0o755, + .errors, .pty_ctl => 0o222, + .pty_status => 0o444, + else => 0o666, + }; + } + + pub fn isDir(f: PaneFile) bool { + return f == .dir or f == .pty; + } + + pub fn inPty(f: PaneFile) bool { + return switch (f) { + .pty, .pty_ctl, .pty_status, .pty_data => true, + else => false, + }; + } +}; + +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 }); + } + + 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, + } }; + } +}; + +pub const Target = union(enum) { + top: TopFile, + pane: struct { serial: u32, file: PaneFile }, +}; + +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; +} + +fn topFileNamed(name: []const u8) ?TopFile { + const f = std.meta.stringToEnum(TopFile, name) orelse return null; + return if (f == .root) null else f; +} + +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; + const serial = std.fmt.parseInt(u32, name, 10) catch return null; + return if (serial == 0) null else serial; +} + +pub fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { + var best: ?u32 = null; + for (p.panes) |slot| { + const pn = slot orelse continue; + if (pn.serial <= after) continue; + if (best == null or pn.serial < best.?) best = pn.serial; + } + return best; +} + +/// An editor path below the root (`pane/3/body`, `index`, `src/...`) to its node. +pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { + var path_buf: [4096]u8 = undefined; + const normalized = fs.normalizeVirtualPath(path, &path_buf) orelse return null; + var parts = std.mem.tokenizeScalar(u8, normalized, '/'); + const first = parts.next() orelse return root; + if (!std.mem.eql(u8, first, "pane")) { + const top = topFileNamed(first) orelse return sources.node(normalized); + if (top == .pane or parts.next() != null) return null; + return @intFromEnum(top); + } + const serial = serialNamed(parts.next() orelse return @intFromEnum(TopFile.pane)) orelse return null; + const id = p.paneBySerial(serial) orelse return null; + const file = paneFileNamed(parts.next() orelse return Node.of(serial, .dir)) orelse return null; + if (file.inPty() and !p.panes[id].?.isTerminal()) return null; + if (parts.next()) |name| { + if (file != .pty) return null; + const child = pty.fileNamed(name) orelse return null; + if (parts.next() != null) return null; + return Node.of(serial, child); + } + return Node.of(serial, file); +} + +// ---- staging ---- + +pub 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; +} + +/// Answers a read from whatever the handler staged, windowed by offset and size. +pub fn stagedReply(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) } }; +} + +// ---- dispatch ---- + +pub fn handle(p: *Pardes, req: Req) Reply { + if (req.node == fs.os_root or req.node & fs.os_node != 0) return fs.osHandle(p, req); + if (req.node & sources.archive_node != 0) return sources.handle(p, req); + const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); + if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { + .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 => attrReply(p, req.tag, target), + .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), + }; +} + +fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { + switch (target) { + .top => |f| return .{ + .name = f.fileName(), + .node = @intFromEnum(f), + .dir = f.dir(), + .mode = f.mode(), + .size = topSize(p, f), + .mtime = p.fs.started, + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return null; + const pn = p.panes[id].?; + if (t.file.inPty() and !pn.isTerminal()) return null; + return .{ + .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.fileName(), + .node = Node.of(t.serial, t.file), + .dir = t.file.isDir(), + .mode = t.file.mode(), + .size = pane.fileSize(p, id, t.file), + .mtime = pane.mtimeOf(p, pn), + }; + }, + } +} + +fn attrReply(p: *Pardes, tag: u64, target: Target) Reply { + const a = attrOf(p, target) orelse return Reply.fail(tag, E.NOENT); + return .{ .tag = tag, .attr = a }; +} + +fn topSize(p: *Pardes, f: TopFile) u64 { + return switch (f) { + .root, .pane, .new, .log, .screen, .listeners => 0, + .index => pane.indexLen(p), + .README => fs.help.len, + .ctl => ctl: { + var buf: [ctl.root_text_cap]u8 = undefined; + break :ctl ctl.rootText(p, &buf).len; + }, + }; +} + +fn lookup(p: *Pardes, req: Req, target: Target) Reply { + const name = req.data; + if (std.mem.eql(u8, name, "..")) { + const parent: u64 = switch (target) { + .top => |top| if (top.dir()) root else return Reply.fail(req.tag, E.NOTDIR), + .pane => |t| switch (t.file) { + .dir => @intFromEnum(TopFile.pane), + .pty => Node.of(t.serial, .dir), + else => return Reply.fail(req.tag, E.NOTDIR), + }, + }; + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent }); + } + if (name.len == 0 or std.mem.indexOfAny(u8, name, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); + const node: u64 = switch (target) { + .top => |f| switch (f) { + .root => root: { + if (std.mem.eql(u8, name, "os")) return fs.osHandle(p, .{ .tag = req.tag, .op = .getattr, .node = fs.os_root }); + if (topFileNamed(name)) |t| break :root @intFromEnum(t); + if (sources.node(name)) |n| return sources.handle(p, .{ .tag = req.tag, .op = .getattr, .node = n }); + return Reply.fail(req.tag, E.NOENT); + }, + .pane => pane: { + const serial = serialNamed(name) orelse return Reply.fail(req.tag, E.NOENT); + break :pane Node.of(serial, .dir); + }, + else => return Reply.fail(req.tag, E.NOTDIR), + }, + .pane => |t| pane: { + _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const f = switch (t.file) { + .dir => paneFileNamed(name), + .pty => pty.fileNamed(name), + else => return Reply.fail(req.tag, E.NOTDIR), + } orelse return Reply.fail(req.tag, E.NOENT); + break :pane Node.of(t.serial, f); + }, + }; + return attrReply(p, req.tag, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT)); +} + +fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { + inline for (comptime std.enums.values(PaneFile)) |f| { + if (comptime f == .dir or (f.inPty() and f != .pty)) continue; + 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.fileName()); + } + } +} + +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.fileName()); + } +} + +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.README, TopFile.index, TopFile.ctl, TopFile.new, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| { + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); + } + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); + sources.stage(p, out, "", &skip); + }, + .pane => { + var last: u32 = 0; + while (nextSerialAfter(p, last)) |serial| { + last = serial; + if (skip > 0) { + skip -= 1; + continue; + } + var buf: [16]u8 = undefined; + const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable; + stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); + } + }, + 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), + .pty => { + if (!terminal) return Reply.fail(req.tag, E.NOENT); + stagePtyFiles(p, out, t.serial, &skip); + }, + else => return Reply.fail(req.tag, E.NOTDIR), + } + }, + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +fn newPane(p: *Pardes) ?u32 { + const slot = p.freeSlot() orelse return null; + p.newScratchBelow(p.active); + const pn = p.panes[slot] orelse return null; + return pn.serial; +} + +fn open(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| switch (f) { + // The clone idiom: each open makes one empty pane; reads answer its serial. + .new => { + const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); + return .{ .tag = req.tag, .handle = serial }; + }, + .screen => return screen.openSnapshot(p, req, true), + .log => p.fs.log_readers +|= 1, + else => {}, + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pn = p.panes[id].?; + const pf = &p.fs.panes[id]; + if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); + switch (t.file) { + .body => if (pn.isTerminal()) return screen.openSnapshot(p, req, false), + .ctl => pf.limit = null, + .addr => { + pf.addr = .{}; + pf.limit = null; + }, + .event => { + pf.readers +|= 1; + p.fs.listeners +|= 1; + }, + .pty_data => pf.pty_readers +|= 1, + else => {}, + } + }, + } + return .{ .tag = req.tag, .handle = 1 }; +} + +fn release(p: *Pardes, req: Req) Reply { + if (screen.releaseSnapshot(p, req)) return .{ .tag = req.tag }; + const target = Node.target(req.node) orelse return .{ .tag = req.tag }; + switch (target) { + .top => |f| if (f == .log and p.fs.log_readers > 0) { + p.fs.log_readers -= 1; + if (p.fs.log_readers == 0) p.fs.log.clearAndFree(p.gpa); + }, + .pane => |t| { + if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; + 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; + 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; + if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); + }, + } + return .{ .tag = req.tag }; +} + +fn setattr(p: *Pardes, req: Req, target: Target) Reply { + if (req.truncate) switch (target) { + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + if (pane.truncate(p, id, p.panes[id].?, t.file) != .ok) return Reply.fail(req.tag, E.NOMEM); + }, + .top => {}, + }; + return attrReply(p, req.tag, target); +} + +fn read(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| return switch (f) { + .README => help: { + const off = @min(req.off, fs.help.len); + const bytes = fs.help[off..][0..@min(req.size, fs.help.len - off)]; + p.fs.stage(p.gpa).appendSlice(p.gpa, bytes) catch break :help Reply.fail(req.tag, E.NOMEM); + break :help .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; + }, + .index => index: { + const out = p.fs.stage(p.gpa); + var last: u32 = 0; + while (nextSerialAfter(p, last)) |serial| { + last = serial; + pane.stageIndexLine(p, out, p.panes[p.paneBySerial(serial).?].?); + } + break :index stagedReply(p, req); + }, + .ctl => ctl.readRoot(p, req), + .new => new: { + if (req.handle == 0) break :new Reply.fail(req.tag, E.INVAL); + p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{req.handle}) catch break :new Reply.fail(req.tag, E.NOMEM); + break :new stagedReply(p, req); + }, + .log => events.readQueue(p, req, &p.fs.log), + .screen => screen.readSnapshot(p, req, null), + .listeners => screen.readListeners(p, req), + .root, .pane => Reply.fail(req.tag, E.PERM), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pn = p.panes[id].?; + if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); + return pane.read(p, req, id, pn, t.file); + }, + } +} + +fn write(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| return switch (f) { + .ctl => ctl.writeRoot(p, req), + else => Reply.fail(req.tag, E.PERM), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pn = p.panes[id].?; + if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); + return pane.write(p, req, id, pn, t.file); + }, + } +} + +// ---- tests ---- + +test { + _ = events; + _ = pane; + _ = addr; + _ = ctl; + _ = pty; + _ = sources; + _ = screen; +} + +const testing = std.testing; +const th = @import("testing.zig"); +const config = @import("../config.zig"); +const call = th.call; +const rd = th.rd; +const wr = th.wr; +const rdir = th.rdir; +const look_up = th.look_up; +const withFile = th.withFile; +const serialOf = th.serialOf; + +test "filesystem inspection preserves pending and displayed Look hover" { + const delay = config.look_preview_delay_frames orelse return; + const gpa = testing.allocator; + const p = try withFile(gpa, "alpha beta gamma\n"); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + p.update(.{ .mouse = .{ + .button = .none, + .kind = .motion, + .col = rect.x + config.GUTTER + config.PREFIX_W + 7, + .row = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, + } }); + try testing.expect(p.look_hover_wait != null); + const body = Node.of(serialOf(p), .body); + const screen_node = @intFromEnum(TopFile.screen); + for (0..2) |phase| { + if (phase == 1) { + for (0..delay) |_| p.update(.tick); + try testing.expect(p.look_hover_preview != null); + } + const waiting = p.look_hover_wait; + const preview = p.look_hover_preview; + const requests = [_]Req{ + .{ .tag = 1, .op = .lookup, .node = root, .data = "screen" }, + .{ .tag = 1, .op = .lookup, .node = root, .data = "new" }, + .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 2, .op = .getattr, .node = body }, + .{ .tag = 3, .op = .open, .node = body }, + .{ .tag = 4, .op = .read, .node = body, .size = 5 }, + .{ .tag = 5, .op = .release, .node = body, .handle = 1 }, + .{ .tag = 6, .op = .readdir, .node = root, .size = 4096 }, + .{ .tag = 7, .op = .open, .node = screen_node }, + .{ .tag = 8, .op = .read, .node = screen_node, .handle = 1, .size = 32 }, + .{ .tag = 9, .op = .release, .node = screen_node, .handle = 1 }, + }; + for (requests) |req| { + const answer = call(p, req); + try testing.expectEqual(Status.ok, answer.reply.status); + try testing.expect(std.meta.eql(waiting, p.look_hover_wait)); + try testing.expect(std.meta.eql(preview, p.look_hover_preview)); + try testing.expect(p.raw_hover_intent); + } + } + try testing.expectEqual(Status.ok, wr(p, body, "changed").reply.status); + try testing.expect(p.look_hover_wait == null); + try testing.expect(p.look_hover_preview == null); + try testing.expect(!p.raw_hover_intent); +} + +test "filesystem pane creation and truncation cancel Look hover" { + const requests = [_]Req{ + .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, + }; + for (requests) |request| { + const p = try withFile(testing.allocator, "word\n"); + defer p.deinit(); + p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = serialOf(p) }; + p.raw_hover_intent = true; + var req = request; + if (req.op == .setattr) req.node = Node.of(serialOf(p), .body); + try testing.expectEqual(Status.ok, call(p, req).reply.status); + try testing.expect(p.look_hover_wait == null); + try testing.expect(p.look_hover_preview == null); + try testing.expect(!p.raw_hover_intent); + } +} + +test "readdir lists the root and a pane directory without creating anything" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + var buf: [32]th.Dirent = undefined; + + const before = p.next_serial; + const top_reply = rdir(p, root, 0); + try testing.expectEqual(Status.ok, top_reply.reply.status); + var top_bytes: [4096]u8 = undefined; + @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); + const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); + try testing.expect(top.len >= 9); + for ([_][]const u8{ "README", "index", "ctl", "new", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| { + try testing.expectEqualStrings(name, top[i].name); + try testing.expectEqual(i >= 7, top[i].dir); + } + try testing.expectEqual(@intFromEnum(TopFile.pane), top[7].node); + try testing.expectEqual(fs.os_root, top[8].node); + var idbuf: [16]u8 = undefined; + try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); + try testing.expect(th.nameAt(top, "self") == null); + try testing.expect(th.nameAt(top, "cons") == null); + + var rest_buf: [32]th.Dirent = undefined; + const rest = rdir(p, root, 3); + try testing.expectEqual(top.len - 3, th.dirents(rest.bytes, &rest_buf).len); + const eof = rdir(p, root, 99); + try testing.expectEqual(Status.ok, eof.reply.status); + try testing.expectEqual(@as(usize, 0), eof.bytes.len); + + // Walk, stat and open-for-read every top entry a browser would, then every pane file. + for (top) |entry| { + if (std.mem.eql(u8, entry.name, "os")) continue; + const inspected = look_up(p, root, entry.name); + try testing.expectEqual(Status.ok, inspected.reply.status); + try testing.expectEqual(entry.node, inspected.reply.attr.node); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }).reply.status); + try testing.expectEqual(before, p.next_serial); + } + const panes_dir = rdir(p, @intFromEnum(TopFile.pane), 0); + const listed = th.dirents(panes_dir.bytes, &buf); + try testing.expectEqual(@as(usize, 1), listed.len); + try testing.expectEqual(Node.of(serial, .dir), listed[0].node); + + const dir = rdir(p, Node.of(serial, .dir), 0); + const files = th.dirents(dir.bytes, &buf); + try testing.expectEqual(@as(usize, 10), files.len); + for ([_][]const u8{ "name", "body", "tag", "ctl", "addr", "data", "xdata", "sel", "errors", "event" }, 0..) |name, i| + try testing.expectEqualStrings(name, files[i].name); + try testing.expect(th.nameAt(files, ".") == null); + try testing.expect(th.nameAt(files, "rdsel") == null); + try testing.expectEqual(Node.of(serial, .body), th.nameAt(files, "body").?.node); + for (files) |entry| { + const stat = call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }); + try testing.expectEqual(Status.ok, stat.reply.status); + try testing.expectEqual(p.fs.started, stat.reply.attr.mtime); + } + try testing.expectEqual(before, p.next_serial); + + try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); + try testing.expectEqual(E.NOTDIR, rdir(p, @intFromEnum(TopFile.index), 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); + + 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(@as(u16, 0o444), look_up(p, root, "new").reply.attr.mode); + try testing.expect(look_up(p, root, "pane").reply.attr.dir); + try testing.expect(look_up(p, root, "os").reply.attr.dir); + try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); + try testing.expectEqual(E.NOENT, look_up(p, root, "self").errno()); + try testing.expectEqual(E.NOENT, look_up(p, root, "cons").errno()); + try testing.expectEqual(E.NOTDIR, look_up(p, @intFromEnum(TopFile.index), "x").errno()); + + var idbuf: [16]u8 = undefined; + const serial_name = try std.fmt.bufPrint(&idbuf, "{d}", .{serial}); + try testing.expectEqual(E.NOENT, look_up(p, root, serial_name).errno()); + const dir = look_up(p, @intFromEnum(TopFile.pane), serial_name); + try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); + try testing.expect(dir.reply.attr.dir); + try testing.expectEqualStrings(serial_name, dir.reply.attr.name); + try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.pane), "99999").errno()); + try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.pane), "0").errno()); + + const body = look_up(p, Node.of(serial, .dir), "body"); + try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); + 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.NOENT, look_up(p, Node.of(serial, .dir), "rdsel").errno()); + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); +} + +test "each open of new creates exactly one pane whose serial the read answers" { + const gpa = testing.allocator; + const p = try withFile(gpa, "first\n"); + defer p.deinit(); + const before = p.next_serial; + const new = @intFromEnum(TopFile.new); + + try testing.expectEqual(Status.ok, look_up(p, root, "new").reply.status); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = new }).reply.status); + try testing.expectEqual(before, p.next_serial); + + const opened = call(p, .{ .tag = 1, .op = .open, .node = new }); + try testing.expectEqual(Status.ok, opened.reply.status); + try testing.expectEqual(before + 1, p.next_serial); + try testing.expectEqual(before + 1, opened.reply.handle); + try testing.expectEqual(@as(u64, 0), opened.reply.attr.node); + var expected: [16]u8 = undefined; + const line = try std.fmt.bufPrint(&expected, "{d}\n", .{opened.reply.handle}); + try testing.expectEqualStrings(line, call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .size = 64 }).bytes); + try testing.expectEqualStrings(line[1..], call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = 1, .size = 64 }).bytes); + try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = line.len, .size = 64 }).bytes); + try testing.expectEqual(before + 1, p.next_serial); + _ = call(p, .{ .tag = 3, .op = .release, .node = new, .handle = opened.reply.handle }); + + const id = p.paneBySerial(opened.reply.handle).?; + try testing.expectEqualStrings("", p.panes[id].?.file.?.content); + _ = wr(p, Node.of(opened.reply.handle, .body), "hi"); + const second = call(p, .{ .tag = 4, .op = .open, .node = new }); + try testing.expectEqual(Status.ok, second.reply.status); + try testing.expect(second.reply.handle != opened.reply.handle); + try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); + try testing.expectEqual(E.INVAL, call(p, .{ .tag = 5, .op = .read, .node = new, .size = 64 }).errno()); +} + +test "directories walk to their parents" { + const p = try withFile(testing.allocator, "contents\n"); + defer p.deinit(); + for ([_]struct { node: u64, parent: u64 }{ + .{ .node = root, .parent = root }, + .{ .node = @intFromEnum(TopFile.pane), .parent = root }, + .{ .node = Node.of(serialOf(p), .dir), .parent = @intFromEnum(TopFile.pane) }, + .{ .node = fs.os_root, .parent = root }, + }) |case| { + const result = look_up(p, case.node, ".."); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expectEqual(case.parent, result.reply.attr.node); + } + try testing.expectEqual(E.NOTDIR, look_up(p, @intFromEnum(TopFile.index), "..").errno()); + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serialOf(p), .body), "..").errno()); +} + +test "editor paths resolve to the same nodes the wire serves" { + const p = try withFile(testing.allocator, "contents\n"); + defer p.deinit(); + const serial = serialOf(p); + var path: [64]u8 = undefined; + try testing.expectEqual(root, resolveSelf(p, "").?); + try testing.expectEqual(root, resolveSelf(p, "/./pane/../").?); + try testing.expectEqual(@intFromEnum(TopFile.index), resolveSelf(p, "index").?); + try testing.expectEqual(@intFromEnum(TopFile.new), resolveSelf(p, "new").?); + try testing.expectEqual(@intFromEnum(TopFile.pane), resolveSelf(p, "pane").?); + try testing.expectEqual(Node.of(serial, .dir), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}", .{serial})).?); + try testing.expectEqual(Node.of(serial, .sel), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/sel", .{serial})).?); + try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/pty/ctl", .{serial})) == null); + try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/rdsel", .{serial})) == null); + try testing.expect(resolveSelf(p, "self/index") == null); + try testing.expect(resolveSelf(p, "new/ctl") == null); + try testing.expect(resolveSelf(p, "cons") == null); + try testing.expect(resolveSelf(p, "pane/0") == null); +} |
