const std = @import("std"); const limits = @import("memory.zig").limits; const libc = std.c; const pardes = @import("pardes.zig"); const config = @import("config.zig"); const lsp = @import("lsp/lsp.zig"); const ninep_io = @import("9p_io.zig"); const panes = @import("panes.zig"); const mvzr = @import("mvzr"); const modal = @import("modal.zig"); const look = @import("look.zig"); pub const name_capacity: usize = 255; pub const Source = struct { path: []const u8, contents: []const u8 }; pub const sources: []const Source = if (limits.embedded_sources) &(source_files ++ @import("effect_sources.zig").files) else &.{}; const source_files = [_]Source{ .{ .path = "build.zig", .contents = @embedFile("root-build.zig") }, .{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") }, .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, .{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") }, .{ .path = "src/look.zig", .contents = @embedFile("look.zig") }, .{ .path = "src/9p.zig", .contents = @embedFile("9p.zig") }, .{ .path = "src/9p_io.zig", .contents = @embedFile("9p_io.zig") }, .{ .path = "src/host_io.zig", .contents = @embedFile("host_io.zig") }, .{ .path = "src/config.zig", .contents = @embedFile("config.zig") }, .{ .path = "src/main.zig", .contents = @embedFile("main.zig") }, .{ .path = "src/builtins.zig", .contents = @embedFile("builtins.zig") }, .{ .path = "src/grammar_manifest.zig", .contents = @embedFile("grammar_manifest.zig") }, .{ .path = "src/CHANGELOG.md", .contents = @embedFile("CHANGELOG.md") }, }; pub fn sourceBytes(path: []const u8) ?[]const u8 { for (sources) |entry| if (std.mem.eql(u8, entry.path, path)) return entry.contents; return null; } pub const WriteError = error{ PathTooLong, PermissionDenied, IsDirectory, ReadOnlyFilesystem, NoSpaceLeft, OpenFailed, WriteFailed, }; pub fn writeFile(path: []const u8, bytes: []const u8) WriteError!void { var pathbuf: [4096:0]u8 = undefined; if (path.len >= pathbuf.len) return error.PathTooLong; if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; @memcpy(pathbuf[0..path.len], path); pathbuf[path.len] = 0; const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return switch (libc.errno(fd)) { .ACCES, .PERM => error.PermissionDenied, .ISDIR => error.IsDirectory, .ROFS => error.ReadOnlyFilesystem, .NOSPC, .DQUOT => error.NoSpaceLeft, .NAMETOOLONG => error.PathTooLong, else => error.OpenFailed, }; var off: usize = 0; var wrote = true; while (off < bytes.len) { const n = libc.write(fd, bytes[off..].ptr, bytes.len - off); if (n < 0 and libc.errno(n) == .INTR) continue; if (n <= 0) { wrote = false; break; } off += @intCast(n); } const closed = libc.close(fd) == 0; if (!wrote or !closed) return error.WriteFailed; } extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; pub const Mount = struct { name: []const u8, dial: []const u8 }; pub const max_mounts = 8; pub const Resolved = struct { path: []const u8, dir: bool = false }; pub const OsPath = struct { node: u64, path: []const u8 }; pub const archive_node: u64 = 1 << 61; pub fn archiveNode(path: []const u8) ?u64 { if (path.len == 0) return archive_node; if (path.len >= 4096) return null; for (sources, 0..) |source, i| { if (std.mem.eql(u8, source.path, path) or (source.path.len > path.len and source.path[path.len] == '/' and std.mem.startsWith(u8, source.path, path))) return archive_node | (@as(u64, i) << 12) | path.len; } return null; } fn archiveInfo(node: u64) ?struct { path: []const u8, contents: []const u8, dir: bool } { if (node == archive_node) return .{ .path = "", .contents = "", .dir = true }; const index = (node & ~archive_node) >> 12; if (index >= sources.len) return null; const source = sources[@intCast(index)]; const len: usize = @intCast(node & 4095); if (len > source.path.len) return null; return .{ .path = source.path[0..len], .contents = source.contents, .dir = len < source.path.len }; } pub fn stageArchive(p: *pardes.Pardes, out: *std.ArrayList(u8), path: []const u8, skip: *u64) void { var seen: [sources.len][]const u8 = undefined; var count: usize = 0; for (sources, 0..) |source, i| { if (path.len != 0 and (source.path.len <= path.len or source.path[path.len] != '/' or !std.mem.startsWith(u8, source.path, path))) continue; const start = if (path.len == 0) 0 else path.len + 1; const rest = source.path[start..]; const len = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len; const name = rest[0..len]; var duplicate = false; for (seen[0..count]) |previous| if (std.mem.eql(u8, name, previous)) { duplicate = true; break; }; if (duplicate) continue; seen[count] = name; count += 1; if (skip.* > 0) { skip.* -= 1; continue; } const node = archive_node | (@as(u64, i) << 12) | (start + len); stageDirent(out, p.gpa, node, len < rest.len, name); } } pub fn archiveHandle(p: *pardes.Pardes, req: Req) Reply { const info = archiveInfo(req.node) orelse return Reply.fail(req.tag, E.NOENT); const attr: Reply.Attr = .{ .node = req.node, .name = std.fs.path.basename(info.path), .dir = info.dir, .size = if (info.dir) 0 else info.contents.len, .mode = if (info.dir) 0o500 else 0o400, }; switch (req.op) { .getattr => return .{ .tag = req.tag, .attr = attr }, .open => return .{ .tag = req.tag, .handle = 1 }, .release => return .{ .tag = req.tag }, .lookup => { if (!info.dir) return Reply.fail(req.tag, E.NOTDIR); if (std.mem.eql(u8, req.data, "..")) { const parent = std.fs.path.dirname(info.path) orelse ""; if (parent.len == 0) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) }); const node = archiveNode(parent) orelse return Reply.fail(req.tag, E.NOENT); return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); } if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); var buf: [4096]u8 = undefined; const path = if (info.path.len == 0) req.data else std.fmt.bufPrint(&buf, "{s}/{s}", .{ info.path, req.data }) catch return Reply.fail(req.tag, E.NOENT); const node = archiveNode(path) orelse return Reply.fail(req.tag, E.NOENT); return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); }, .readdir => { if (!info.dir) return Reply.fail(req.tag, E.NOTDIR); const out = p.fs.stage(p.gpa); var skip = req.off; stageArchive(p, out, info.path, &skip); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; }, .read => { if (info.dir) return Reply.fail(req.tag, E.PERM); const off: usize = @intCast(@min(req.off, info.contents.len)); const bytes = info.contents[off..][0..@min(req.size, info.contents.len - off)]; const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, bytes) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; }, else => return Reply.fail(req.tag, E.PERM), } } fn osPath(p: *pardes.Pardes, node: u64) ?[]const u8 { if (node == os_root) return "/"; for (p.fs.os_paths.items) |entry| if (entry.node == node) return entry.path; return null; } fn osNode(p: *pardes.Pardes, path: []const u8) !u64 { if (std.mem.eql(u8, path, "/")) return os_root; const node = os_node | (std.hash.Wyhash.hash(0, path) & (os_node - 1)); for (p.fs.os_paths.items) |entry| { if (std.mem.eql(u8, entry.path, path)) return entry.node; if (entry.node == node) return error.NodeCollision; } if (p.fs.os_paths.items.len == 4096) return error.TooManyFiles; const saved = try p.gpa.dupe(u8, path); errdefer p.gpa.free(saved); try p.fs.os_paths.append(p.gpa, .{ .node = node, .path = saved }); return node; } pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { if (comptime !platform_has_fs) return Reply.fail(req.tag, E.NOENT); const path = osPath(p, req.node) orelse return Reply.fail(req.tag, E.NOENT); if (req.op == .release) return .{ .tag = req.tag }; const io = std.Io.Threaded.global_single_threaded.io(); const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o500 else 0o600 }; switch (req.op) { .getattr => return .{ .tag = req.tag, .attr = attr }, .open => { if (stat.kind != .file and stat.kind != .directory) return Reply.fail(req.tag, E.PERM); return .{ .tag = req.tag, .handle = 1 }; }, .lookup => { if (stat.kind != .directory) return Reply.fail(req.tag, E.NOTDIR); if (req.node == os_root and std.mem.eql(u8, req.data, "..")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = namespace_root }); if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); var buf: [4096]u8 = undefined; const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), req.data }) catch return Reply.fail(req.tag, E.NOENT); var normalized_buf: [4096]u8 = undefined; const normalized = resolveOs(joined, &normalized_buf) orelse return Reply.fail(req.tag, E.NOENT); const node = osNode(p, normalized.path) catch return Reply.fail(req.tag, E.NFILE); return osHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); }, .readdir => { var dir = std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }) catch return Reply.fail(req.tag, E.NOTDIR); defer dir.close(io); var it = dir.iterate(); const out = p.fs.stage(p.gpa); var skip = req.off; while (it.next(io) catch return Reply.fail(req.tag, E.IO)) |entry| { var buf: [4096]u8 = undefined; const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), entry.name }) catch continue; var normalized_buf: [4096]u8 = undefined; const normalized = resolveOs(joined, &normalized_buf) orelse continue; const child = dir.statFile(io, entry.name, .{}) catch continue; if (skip > 0) { skip -= 1; continue; } const node = if (std.mem.eql(u8, normalized.path, "/")) os_root else os_node | (std.hash.Wyhash.hash(0, normalized.path) & (os_node - 1)); stageDirent(out, p.gpa, node, child.kind == .directory, entry.name); if (out.items.len >= @max(req.size, 512)) break; } return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; }, .read, .write, .setattr => { if (stat.kind != .file) return Reply.fail(req.tag, E.PERM); var z: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return Reply.fail(req.tag, E.NOENT); const fd = libc.open(path_z, .{ .ACCMODE = if (req.op == .read) .RDONLY else .WRONLY, .NONBLOCK = true, .CLOEXEC = true }); if (fd < 0) return Reply.fail(req.tag, E.PERM); defer _ = libc.close(fd); if (req.op == .setattr) { if (!req.truncate or req.off != 0 or libc.ftruncate(fd, 0) != 0) return Reply.fail(req.tag, E.INVAL); var truncated = attr; truncated.size = 0; return .{ .tag = req.tag, .attr = truncated }; } if (req.off > std.math.maxInt(i64)) return Reply.fail(req.tag, E.INVAL); if (libc.lseek(fd, @intCast(req.off), libc.SEEK.SET) < 0) return Reply.fail(req.tag, E.IO); if (req.op == .write) { const written = libc.write(fd, req.data.ptr, req.data.len); if (written < 0) return Reply.fail(req.tag, E.IO); return .{ .tag = req.tag, .written = @intCast(written) }; } const out = p.fs.stage(p.gpa); out.resize(p.gpa, @min(req.size, 65536)) catch return Reply.fail(req.tag, E.NOMEM); const got = libc.read(fd, out.items.ptr, out.items.len); if (got < 0) return Reply.fail(req.tag, E.IO); out.shrinkRetainingCapacity(@intCast(got)); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(got) } }; }, else => return Reply.fail(req.tag, E.PERM), } } pub fn validMountName(name: []const u8) bool { if (name.len == 0 or name.len > name_capacity or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; if (std.mem.eql(u8, name, "os") or std.mem.eql(u8, name, "self")) return false; for (name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') return false; return true; } test "mounts own their names and dials and reject duplicate or reserved names" { const gpa = std.testing.allocator; var ns: Namespace = .{}; defer ns.deinit(gpa); var name = "peer".*; var dial = "/tmp/peer.sock".*; try ns.mount(gpa, &name, &dial); @memset(&name, 'x'); @memset(&dial, 'x'); try std.testing.expectEqualStrings("peer", ns.mounts.items[0].name); try std.testing.expectEqualStrings("/tmp/peer.sock", ns.mounts.items[0].dial); try std.testing.expectError(error.AlreadyMounted, ns.mount(gpa, "peer", "/tmp/other.sock")); for ([_][]const u8{ "", ".", "..", "os", "self", "a/b", "with space" }) |bad| try std.testing.expectError(error.BadMountName, ns.mount(gpa, bad, "/tmp/peer.sock")); for ([_][]const u8{ "", "a\x00b", "tcp!127.0.0.1!0", "tcp!localhost!564" }) |bad| try std.testing.expectError(error.BadDial, ns.mount(gpa, "valid", bad)); for (1..max_mounts) |i| { var buf: [16]u8 = undefined; try ns.mount(gpa, try std.fmt.bufPrint(&buf, "peer{d}", .{i}), "/tmp/peer.sock"); } try std.testing.expectEqual(max_mounts, ns.mounts.items.len); try std.testing.expectError(error.TooManyMounts, ns.mount(gpa, "full", "/tmp/peer.sock")); } test "mount allocation failures preserve prior mounts and release partial copies" { const Check = struct { fn run(gpa: std.mem.Allocator) !void { var ns: Namespace = .{}; defer ns.deinit(gpa); try ns.mount(gpa, "first", "/tmp/first.sock"); ns.mount(gpa, "second", "/tmp/second.sock") catch |err| { try std.testing.expectEqual(@as(usize, 1), ns.mounts.items.len); try std.testing.expectEqualStrings("first", ns.mounts.items[0].name); try std.testing.expectEqualStrings("/tmp/first.sock", ns.mounts.items[0].dial); return err; }; } }; try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); } test "unmount refuses pane paths inherited directories and queued save targets" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); try p.fs.mount(gpa, "peer", "/tmp/peer.sock"); const pane = try p.setTestFile("unsaved\n"); for ([_][]const u8{ "/n/peer", "/n/peer/file", "/n/peer/dir/file" }) |path| { gpa.free(pane.file.?.path); pane.file.?.path = try gpa.dupe(u8, path); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); } gpa.free(pane.file.?.path); pane.file.?.path = try gpa.dupe(u8, "/n/peer2/file"); const shell = try p.newShell(1, "/n/peer/dir"); p.setCwd(1, "/n/peer/dir"); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); pane.cwd = .{ .inherited = shell }; try std.testing.expectError(error.MountInUse, unmount(p, "peer")); p.setCwd(1, "/"); while (p.nextEffect()) |_| {} p.emit(.{ .save_text = .{ .pane = 0, .serial = pane.serial, .path = .from("/n/peer/pending") } }); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); while (p.nextEffect()) |_| {} try unmount(p, "peer"); try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); try std.testing.expectError(error.NotMounted, unmount(p, "peer")); try p.fs.mount(gpa, "peer", "/tmp/other.sock"); try std.testing.expectEqualStrings("/tmp/other.sock", p.fs.mounts.items[0].dial); } test "virtual writes enforce permissions even when contents are empty" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); for ([_][]const u8{ "", "bytes" }) |bytes| { for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/src/fs.zig", "/n/self/src/fs.zig" }) |path| try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes)); try std.testing.expectError(error.IsDirectory, write(p, "/virtual/src", bytes)); try std.testing.expectError(error.FileNotFound, write(p, "/virtual/missing", bytes)); } for (p.fs.snapshots) |snapshot| try std.testing.expect(snapshot.node == 0); } test "direct self reads and writes use the same dot paths as Look" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("old contents\n"); var path_buf: [256]u8 = undefined; for ([_][]const u8{ "/virtual", "/n/self" }) |root| { const path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/{d}/../{d}/body/./", .{ root, pane.serial, pane.serial }); try write(p, path, "replacement\n"); try std.testing.expectEqualStrings("replacement\n", pane.file.?.content); const bytes = try read(p, path); defer gpa.free(bytes); try std.testing.expectEqualStrings(pane.file.?.content, bytes); var resolved: [4096]u8 = undefined; const canonical = resolve(p, path, "/", &resolved).?; try std.testing.expectEqual(resolveSelf(p, canonical.path[9..]), resolveSelf(p, path[root.len..])); try std.testing.expectEqual(@as(?u64, @intFromEnum(SelfFile.root)), resolveSelf(p, "./pane/../../")); const index_path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/../index", .{root}); try std.testing.expectError(error.ReadOnlyFilesystem, write(p, index_path, "")); const index = try read(p, index_path); defer gpa.free(index); try std.testing.expect(std.mem.indexOf(u8, index, "/test.txt") != null); } var overlong: [4110]u8 = @splat('x'); @memcpy(overlong[0..9], "/virtual/"); var resolved: [4096]u8 = undefined; try std.testing.expect(resolve(p, &overlong, "/", &resolved) == null); try std.testing.expect(resolveSelf(p, overlong[9..]) == null); try std.testing.expectError(error.FileNotFound, write(p, &overlong, "")); } test "self files share the regular file limit without preallocating it" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const contents = try gpa.alloc(u8, limits.max_stream_bytes + 17); defer gpa.free(contents); @memset(contents, 'x'); const pane = try p.setTestFile(contents); var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{pane.serial}); const bytes = try read(p, path); defer gpa.free(bytes); try std.testing.expectEqualSlices(u8, contents, bytes); } test "bounded reads accept exact OS file lengths and empty files" { if (!platform_has_fs) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var contents: [8209]u8 = @splat('x'); contents[contents.len - 1] = '\n'; for ([_][]const u8{ &contents, "" }) |expected| { try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "limited.txt", .data = expected }); var path_buf: [4096]u8 = undefined; const native = path_buf[0..try tmp.dir.realPathFile(std.testing.io, "limited.txt", &path_buf)]; var explicit_buf: [4096]u8 = undefined; const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{native}); for ([_][]const u8{ native, explicit }) |path| { const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualSlices(u8, expected, bytes); if (expected.len > 0) { try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); } } } try tmp.dir.createDir(std.testing.io, "empty", .default_dir); var path_buf: [4096]u8 = undefined; const native = path_buf[0..try tmp.dir.realPathFile(std.testing.io, "empty", &path_buf)]; var explicit_buf: [4096]u8 = undefined; const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{native}); const empty = try readLimit(p, explicit, 0); defer gpa.free(empty); try std.testing.expectEqual(@as(usize, 0), empty.len); try std.testing.expectEqual(@as(usize, 0), p.fs.os_paths.items.len); } test "bounded reads apply the same limits to self bodies and embedded sources" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); p.fs.socket_path = "/tmp/pardes-limited-in-process.sock"; try p.fs.mount(gpa, "own", p.fs.socket_path); var contents: [8209]u8 = @splat('x'); contents[contents.len - 1] = '\n'; for ([_][]const u8{ &contents, "" }) |expected| { const pane = try p.setTestFile(expected); for ([_][]const u8{ "/virtual", "/n/self", "/n/own/self" }) |prefix| { if (!platform_has_fs and std.mem.eql(u8, prefix, "/n/own/self")) continue; var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "{s}/pane/{d}/body", .{ prefix, pane.serial }); const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualSlices(u8, expected, bytes); if (expected.len > 0) { try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); } try std.testing.expectEqualSlices(u8, expected, pane.file.?.content); } } if (limits.embedded_sources) { const source = findEmbeddedSource("src/look.zig", false).?; for ([_][]const u8{ "/virtual/src/look.zig", "/n/self/src/look.zig", "/n/own/self/src/look.zig" }) |path| { if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own/")) continue; const bytes = try readLimit(p, path, source.contents.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(source.contents, bytes); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, source.contents.len - 1)); } const bytes = try readFileLimit(gpa, "/virtual/src/look.zig", source.contents.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(source.contents, bytes); try std.testing.expectError(error.FileTooLarge, readFileLimit(gpa, "/virtual/src/look.zig", source.contents.len - 1)); } } test "bounded reads count rendered directory paths and unknown self lengths" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); p.fs.socket_path = "/tmp/pardes-limited-in-process.sock"; try p.fs.mount(gpa, "own", p.fs.socket_path); for ([_][]const u8{ "/n", "/virtual", "/n/self", "/n/own", "/n/own/self", "/virtual/listeners" }) |path| { if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own")) continue; const expected = try read(p, path); defer gpa.free(expected); try std.testing.expect(expected.len > 0); const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(expected, bytes); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); } try std.testing.expectEqual(@as(usize, 0), p.fs.os_paths.items.len); } test "bounded reads release terminal snapshots after success and size refusal" { if (!pardes.terminal_panes) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try withTerm(gpa); defer p.deinit(); var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{serialOf(p)}); const empty = try readLimit(p, path, 0); defer gpa.free(empty); try std.testing.expectEqual(@as(usize, 0), empty.len); p.update(.{ .output = .{ .pane = 0, .bytes = "limited terminal output" } }); const expected = "limited terminal output"; const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(expected, bytes); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); for (p.fs.snapshots) |snapshot| { try std.testing.expectEqual(@as(u64, 0), snapshot.node); try std.testing.expect(snapshot.bytes == null); } } test "bounded reads probe size-unknown proc files at the exact limit" { if (!platform_has_fs or @import("builtin").os.tag != .linux) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const expected = read(p, "/proc/version") catch |err| return switch (err) { error.FileNotFound, error.PermissionDenied => error.SkipZigTest, else => err, }; defer gpa.free(expected); try std.testing.expect(expected.len > 0); var storage: [256 * 1024]u8 = undefined; var fixed = std.heap.FixedBufferAllocator.init(&storage); const bounded = try readFileLimit(fixed.allocator(), "/proc/version", limits.max_stream_bytes); try std.testing.expectEqualStrings(expected, bounded); fixed.allocator().free(bounded); for ([_][]const u8{ "/proc/version", "/n/os/proc/version" }) |path| { const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(expected, bytes); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); } } test "self file reads release partial allocations when memory runs out" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const contents = try gpa.alloc(u8, 65537); defer gpa.free(contents); @memset(contents, 'x'); const pane = try p.setTestFile(contents); var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{pane.serial}); const Read = struct { fn run(allocator: std.mem.Allocator, core: *Pardes, name: []const u8) !void { const original = core.gpa; core.gpa = allocator; defer core.gpa = original; const bytes = read(core, name) catch |err| return switch (err) { error.WriteFailed => error.OutOfMemory, else => err, }; defer allocator.free(bytes); try std.testing.expectEqual(@as(usize, 65537), bytes.len); } }; try std.testing.checkAllAllocationFailures(gpa, Read.run, .{ p, path }); try std.testing.expectEqualSlices(u8, contents, pane.file.?.content); } test "oversized OS files fail before allocating their contents" { if (!platform_has_fs) return error.SkipZigTest; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const file = try tmp.dir.createFile(std.testing.io, "oversized", .{}); defer file.close(std.testing.io); try file.setLength(std.testing.io, limits.max_file_bytes + 1); var path_buf: [4096]u8 = undefined; const path_len = try tmp.dir.realPathFile(std.testing.io, "oversized", &path_buf); var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{ .fail_index = 0 }); try std.testing.expectError(error.FileTooLarge, readFile(failing.allocator(), path_buf[0..path_len])); try std.testing.expectEqual(@as(usize, 0), failing.allocations); } test "filesystem roots list their namespaces without dialing remote mounts" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); try p.fs.mount(gpa, "peer", "unavailable-session"); for ([_][]const u8{ "/n", "/n/", "/n///" }) |path| { const contents = try read(p, path); defer gpa.free(contents); try std.testing.expectEqualStrings("/n/os/\n/n/self/\n/n/peer/\n", contents); var buf: [4096]u8 = undefined; try std.testing.expectEqualStrings("/n", resolve(p, path, "/", &buf).?.path); try std.testing.expectError(error.IsDirectory, write(p, path, "")); } const bare = try read(p, "/virtual"); defer gpa.free(bare); const slashed = try read(p, "/virtual/"); defer gpa.free(slashed); try std.testing.expectEqualStrings(bare, slashed); try std.testing.expect(std.mem.indexOf(u8, bare, "/virtual/index\n") != null); try std.testing.expectError(error.IsDirectory, write(p, "/virtual", "")); } test "virtual body writes can read their input from the same pane" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("same pane contents\n"); const event_node = Node.of(pane.serial, .event); _ = handle(p, .{ .tag = 0, .op = .open, .node = event_node }); defer _ = handle(p, .{ .tag = 0, .op = .release, .node = event_node }); var path_buffer: [64]u8 = undefined; const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{pane.serial}); try write(p, path, pane.file.?.content); try std.testing.expectEqualStrings("same pane contents\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 2), pane.file.?.history.undo_len); const events = &p.fs.panes[p.active].events; try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "ED")); events.pop(); try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "EI")); events.pop(); try std.testing.expect(events.empty()); try write(p, path, pane.file.?.content[5..]); try std.testing.expectEqualStrings("pane contents\n", pane.file.?.content); } test "same-core mount directories preserve their mount prefix across reads" { if (!platform_has_fs) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const socket_path = "/tmp/pardes-in-process-mount.sock"; p.fs.socket_path = socket_path; try p.fs.mount(gpa, "own", socket_path); const roots = try read(p, "/n/own"); defer gpa.free(roots); try std.testing.expectEqualStrings("/n/own/os/\n/n/own/self/\n", roots); const self = try read(p, "/n/own/self"); defer gpa.free(self); try std.testing.expect(std.mem.indexOf(u8, self, "/n/own/self/index\n") != null); try std.testing.expectError(error.NotADirectory, read(p, "/n/own/self/index/..")); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); for (0..520) |i| { var name: [32]u8 = undefined; try tmp.dir.writeFile(std.testing.io, .{ .sub_path = try std.fmt.bufPrint(&name, "entry-{d:0>4}.txt", .{i}), .data = "child\n" }); } var directory_buffer: [4096]u8 = undefined; const directory = directory_buffer[0..try tmp.dir.realPath(std.testing.io, &directory_buffer)]; const held_node = try osNode(p, directory); const path = try std.fmt.allocPrint(gpa, "/n/own/os{s}", .{directory}); defer gpa.free(path); const listing = try read(p, path); defer gpa.free(listing); var entries = std.mem.tokenizeScalar(u8, listing, '\n'); var count: usize = 0; while (entries.next()) |entry| { try std.testing.expect(std.mem.startsWith(u8, entry, path)); const child = try read(p, entry); defer gpa.free(child); try std.testing.expectEqualStrings("child\n", child); try std.testing.expectEqual(@as(usize, 1), p.fs.os_paths.items.len); count += 1; } try std.testing.expectEqual(@as(usize, 520), count); try std.testing.expectEqual(held_node, p.fs.os_paths.items[0].node); try std.testing.expectEqualStrings(directory, p.fs.os_paths.items[0].path); } pub fn isVirtual(path: []const u8) bool { return std.mem.eql(u8, path, "/virtual") or std.mem.startsWith(u8, path, "/virtual/") or std.mem.eql(u8, path, "/n") or std.mem.startsWith(u8, path, "/n/"); } pub fn localPath(path: []const u8) ?[]const u8 { if (std.mem.eql(u8, path, "/n/os")) return "/"; if (std.mem.startsWith(u8, path, "/n/os/")) return path[5..]; if (isVirtual(path)) return null; return path; } test "explicit OS paths retain their namespace until an OS boundary" { for ([_]struct { declared: []const u8, native: ?[]const u8 }{ .{ .declared = "/n/os", .native = "/" }, .{ .declared = "/n/os/project/file", .native = "/project/file" }, .{ .declared = "/n/os/virtual/index", .native = "/virtual/index" }, .{ .declared = "/n/os/n/self/index", .native = "/n/self/index" }, .{ .declared = "/n/self/index", .native = null }, .{ .declared = "/n/peer/os/project/file", .native = null }, .{ .declared = "/virtual/index", .native = null }, .{ .declared = "/project/file", .native = "/project/file" }, }) |case| { const native = localPath(case.declared); if (case.native) |expected| try std.testing.expectEqualStrings(expected, native.?) else try std.testing.expect(native == null); } } pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[4096]u8) ?Resolved { if (word.len == 0 or std.mem.indexOfScalar(u8, word, 0) != null) return null; var joined_buf: [4096]u8 = undefined; const joined = if (word[0] == '/') word else std.fmt.bufPrint(&joined_buf, "{s}/{s}", .{ cwd, word }) catch return null; if (std.mem.eql(u8, std.mem.trimEnd(u8, joined, "/"), "/n")) return .{ .path = "/n" }; if (std.mem.startsWith(u8, joined, "/n/")) { const explicit = joined[3..]; const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; const name = explicit[0..cut]; const path = if (cut < explicit.len) explicit[cut..] else "/"; if (std.mem.eql(u8, name, "os")) { var native_buf: [4096]u8 = undefined; const native = resolveOs(path, &native_buf) orelse return null; return .{ .path = std.fmt.bufPrint(out, "/n/os{s}", .{native.path}) catch return null }; } if (std.mem.eql(u8, name, "self")) return resolveVirtual(p, path, out); const core = p orelse return null; for (core.fs.mounts.items) |mount| { if (!std.mem.eql(u8, mount.name, name)) continue; const normalized = normalizeVirtualPath(path, out) orelse return null; var remote_path: [4096]u8 = undefined; const saved = std.fmt.bufPrint(&remote_path, "/n/{s}/{s}", .{ name, normalized }) catch return null; @memcpy(out[0..saved.len], saved); return .{ .path = out[0..saved.len] }; } return null; } if (std.mem.eql(u8, joined, "/virtual")) return resolveVirtual(p, "/", out); if (std.mem.startsWith(u8, joined, "/virtual/")) return resolveVirtual(p, joined[8..], out); if (resolveOs(joined, out)) |found| return found; if (resolveVirtual(p, joined, out)) |found| return found; if (resolveVirtual(p, word, out)) |found| return found; if (resolveEmbedded(word, cwd, out)) |source| { const path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null; return .{ .path = path }; } return null; } pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved { if (comptime !platform_has_fs) return null; var z: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return null; const resolved = realpath(path_z, out) orelse return null; return .{ .path = std.mem.span(resolved), .dir = isDir(resolved) }; } fn resolveVirtual(p: ?*pardes.Pardes, path: []const u8, out: *[4096]u8) ?Resolved { var normalized_buf: [4096]u8 = undefined; const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return null; if (p) |core| if (resolveSelf(core, normalized) != null) return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; if (findEmbeddedSource(normalized, false)) |source| return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null }; if (archiveNode(normalized) != null) return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; return null; } pub fn read(p: *pardes.Pardes, path: []const u8) ![]u8 { return readLimit(p, path, limits.max_file_bytes); } pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { const limit = @min(max_bytes, limits.max_file_bytes); if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); if (limit < "/n/os/\n/n/self/\n".len) return error.FileTooLarge; try out.writer.writeAll("/n/os/\n/n/self/\n"); for (p.fs.mounts.items) |mount| { if (mount.name.len + 5 > limit - out.written().len) return error.FileTooLarge; try out.writer.print("/n/{s}/\n", .{mount.name}); } return out.toOwnedSlice(); } if (std.mem.eql(u8, path, "/virtual")) return readNode(p, @intFromEnum(SelfFile.root), path, limit); if (std.mem.startsWith(u8, path, "/n/")) { const explicit = path[3..]; const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; const name = explicit[0..cut]; const remote_path = if (cut < explicit.len) explicit[cut..] else "/"; if (std.mem.eql(u8, name, "os")) { var native_buf: [4096]u8 = undefined; const native = resolveOs(remote_path, &native_buf) orelse return readFileLimit(p.gpa, path, limit); if (!native.dir) return readFileLimit(p.gpa, path, limit); const saved_paths = p.fs.os_paths.items.len; defer { for (p.fs.os_paths.items[saved_paths..]) |temporary| p.gpa.free(temporary.path); p.fs.os_paths.shrinkRetainingCapacity(saved_paths); } const node = try osNode(p, native.path); return readNode(p, node, path, limit); } if (std.mem.eql(u8, name, "self")) { const node = resolveSelf(p, remote_path) orelse return error.FileNotFound; return readNode(p, node, path, limit); } for (p.fs.mounts.items) |mount| { if (!std.mem.eql(u8, name, mount.name)) continue; if (ninep_io.Client.sameSession(mount.dial, p.fs.socket_path, p.fs.tcp_address, p.fs.quic_address)) { const saved_paths = p.fs.os_paths.items.len; defer { for (p.fs.os_paths.items[saved_paths..]) |temporary| p.gpa.free(temporary.path); p.fs.os_paths.shrinkRetainingCapacity(saved_paths); } var node = namespace_root; var directory = true; var parts = std.mem.tokenizeScalar(u8, remote_path, '/'); while (parts.next()) |part| { if (!directory) return error.NotADirectory; if (std.mem.eql(u8, part, ".")) continue; const reply = handle(p, .{ .tag = 0, .op = .lookup, .node = node, .data = part }); if (reply.status != .ok) return error.FileNotFound; node = reply.attr.node; directory = reply.attr.dir; } return readNode(p, node, path, limit); } return ninep_io.Client.readLimit(p.gpa, mount.dial, remote_path, path, limit); } return error.FileNotFound; } if (std.mem.startsWith(u8, path, "/virtual/")) { const name = path[9..]; if (resolveSelf(p, name)) |node| return readNode(p, node, path, limit); } return readFileLimit(p.gpa, path, limit); } fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize) ![]u8 { var node = initial_node; const attr = handle(p, .{ .tag = 0, .op = .getattr, .node = node }); if (attr.status != .ok) return error.FileNotFound; if (attr.attr.dir) { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); var index: u64 = 0; const max_bytes = @min(limit, limits.max_stream_bytes); const prefix = std.mem.trimEnd(u8, path, "/"); while (true) { const reply = handle(p, .{ .tag = 0, .op = .readdir, .node = node, .off = index, .size = 8192 }); if (reply.status != .ok) return error.ReadFailed; const entries = p.fsPayload(reply); if (entries.len == 0) return out.toOwnedSlice(); var off: usize = 0; while (off + 10 <= entries.len) { const len: usize = entries[off + 9]; if (off + 10 + len > entries.len) return error.ReadFailed; const row_len = prefix.len + len + 2 + @as(usize, @intFromBool(entries[off + 8] != 0)); if (row_len > max_bytes - out.written().len) return error.FileTooLarge; try out.writer.print("{s}/{s}", .{ prefix, entries[off + 10 ..][0..len] }); if (entries[off + 8] != 0) try out.writer.writeByte('/'); try out.writer.writeByte('\n'); off += 10 + len; index += 1; } if (off != entries.len) return error.ReadFailed; } } if (attr.attr.size > limit) return error.FileTooLarge; const opened = handle(p, .{ .tag = 0, .op = .open, .node = node }); if (opened.status != .ok) return error.OpenFailed; if (opened.attr.node != 0) node = opened.attr.node; defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); while (true) { const want: u32 = @intCast(@min(8192, limit + 1 - out.written().len)); const reply = handle(p, .{ .tag = 0, .op = .read, .node = node, .handle = opened.handle, .off = out.written().len, .size = want }); if (reply.status == .again) return error.NotAFile; if (reply.status != .ok) return error.ReadFailed; const bytes = p.fsPayload(reply); if (bytes.len == 0) return out.toOwnedSlice(); if (bytes.len > limit - out.written().len) return error.FileTooLarge; try out.writer.writeAll(bytes); } } pub fn write(p: *pardes.Pardes, path: []const u8, bytes: []const u8) !void { if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) return error.IsDirectory; var self_path: ?[]const u8 = null; if (std.mem.eql(u8, path, "/virtual")) self_path = ""; if (std.mem.startsWith(u8, path, "/virtual/")) self_path = path[9..]; if (std.mem.startsWith(u8, path, "/n/")) { const explicit = path[3..]; const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; const name = explicit[0..cut]; const remote_path = if (cut < explicit.len) explicit[cut..] else "/"; if (std.mem.eql(u8, name, "os")) return writeFile(remote_path, bytes); if (std.mem.eql(u8, name, "self")) { self_path = remote_path; } else { for (p.fs.mounts.items) |mount| { if (!std.mem.eql(u8, name, mount.name)) continue; if (ninep_io.Client.sameSession(mount.dial, p.fs.socket_path, p.fs.tcp_address, p.fs.quic_address)) { var buf: [4096]u8 = undefined; const local = try std.fmt.bufPrint(&buf, "/n{s}", .{remote_path}); return write(p, local, bytes); } return ninep_io.Client.write(p.gpa, mount.dial, remote_path, bytes); } return error.FileNotFound; } } if (self_path) |name| { var node = resolveSelf(p, name) orelse return error.FileNotFound; const attributes = handle(p, .{ .tag = 0, .op = .getattr, .node = node }); if (attributes.status != .ok) return error.FileNotFound; if (attributes.attr.dir) return error.IsDirectory; if (attributes.attr.mode & 0o200 == 0) return error.ReadOnlyFilesystem; const opened = handle(p, .{ .tag = 0, .op = .open, .node = node }); if (opened.status != .ok) return error.OpenFailed; if (opened.attr.node != 0) node = opened.attr.node; defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); var preserved: ?[]u8 = null; defer if (preserved) |copy| p.gpa.free(copy); const target = Node.target(node); if (target != null and target.? == .pane and target.?.pane.file == .body) { preserved = try p.gpa.dupe(u8, bytes); const trunc = handle(p, .{ .tag = 0, .op = .setattr, .node = node, .truncate = true }); if (trunc.status != .ok) return error.WriteFailed; } const contents = preserved orelse bytes; var off: usize = 0; while (off < contents.len) { const reply = handle(p, .{ .tag = 0, .op = .write, .node = node, .off = off, .data = contents[off..] }); if (reply.status != .ok or reply.written == 0) return error.WriteFailed; off += reply.written; } return; } return writeFile(path, bytes); } fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?Source { var wordbuf: [4096]u8 = undefined; const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null; if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true); var joined: [4096]u8 = undefined; if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate| if (normalizeVirtualPath(candidate, scratch)) |normalized| if (findEmbeddedSource(normalized, true)) |source| return source; return findEmbeddedSource(normalized_word, false); } fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { if (std.mem.indexOfScalar(u8, path, 0) != null) return null; var len: usize = 0; var parts = std.mem.tokenizeAny(u8, path, "/\\"); while (parts.next()) |part| { if (std.mem.eql(u8, part, ".")) continue; if (std.mem.eql(u8, part, "..")) { while (len > 0 and out[len - 1] != '/') len -= 1; if (len > 0) len -= 1; continue; } const extra = part.len + @intFromBool(len != 0); if (len + extra > out.len) return null; if (len != 0) { out[len] = '/'; len += 1; } @memcpy(out[len..][0..part.len], part); len += part.len; } return out[0..len]; } fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?Source { for (sources) |source| if (std.mem.eql(u8, source.path, path)) return source; if (!allow_root_suffix) return null; for (sources) |source| { if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue; if (std.mem.endsWith(u8, path, source.path)) return source; } return null; } pub const platform_has_fs = !pardes.isolated and switch (pardes.platform) { .tty, .gui, .macos => true, .web, .esp32p4 => false, }; const find_max_hits = 512; const find_max_depth = 16; const find_max_steps = 100_000; pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320); const find_skip = [_][]const u8{ ".git", ".jj", "target", "node_modules", ".venv", "__pycache__", ".zig-cache", "zig-out", }; pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8) !usize { var hits: [find_max_hits][]const u8 = undefined; var hits_len: usize = 0; if (platform_has_fs) { const io = std.Io.Threaded.global_single_threaded.io(); var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); defer root.close(io); var w = try root.walkSelectively(arena); defer w.deinit(); var steps: usize = 0; walk: while (steps < find_max_steps and hits_len < hits.len) { steps += 1; // an unreadable dir burns a step too, so it cannot spin const e = (try w.next(io)) orelse break; if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) { hits[hits_len] = try arena.dupe(u8, e.path); hits_len += 1; } if (e.kind != .directory or e.depth() >= find_max_depth) continue; for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; try w.enter(io, e); } } else { for (sources) |s| { if (hits_len >= hits.len) break; if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null) { hits[hits_len] = s.path; hits_len += 1; } } } std.mem.sort([]const u8, hits[0..hits_len], {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } }.lt); var written: usize = 0; for (hits[0..hits_len]) |h| { if (h.len + 1 > out.len - written) break; @memcpy(out[written..][0..h.len], h); written += h.len; out[written] = '\n'; written += 1; } return written; } const grep_max_bytes = 256 * 1024; const grep_max_files = 20_000; const GrepResult = struct { bytes: usize, hits: usize }; fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budget: usize) GrepResult { var result: GrepResult = .{ .bytes = 0, .hits = 0 }; var line: usize = 0; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |raw| { line += 1; if (result.hits >= budget) break; const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue; const ln = std.mem.trimEnd(u8, raw, " \t\r"); var cut = @min(ln.len, 200); while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1; const row = std.fmt.bufPrint(out[result.bytes..], "{s}:{d}:{d}{c}{d} {s}\n", .{ path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut], }) catch break; result.bytes += row.len; result.hits += 1; } return result; } pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8) !usize { var hits: usize = 0; var written: usize = 0; if (!platform_has_fs) { for (sources) |s| { if (hits >= find_max_hits or written == out.len) break; const result = grepText(s.path, s.contents, pat, out[written..], find_max_hits - hits); hits += result.hits; written += result.bytes; } return written; } const root_path = std.mem.trimEnd(u8, dir, "/"); const home = std.mem.trimEnd(u8, base, "/"); const files = try arena.alloc([]const u8, grep_max_files); var files_len: usize = 0; { const io = std.Io.Threaded.global_single_threaded.io(); var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); defer root.close(io); var w = try root.walkSelectively(arena); defer w.deinit(); var steps: usize = 0; walk: while (steps < find_max_steps and files_len < files.len) { steps += 1; const e = (try w.next(io)) orelse break; if (e.kind == .directory) { if (e.depth() >= find_max_depth) continue; for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; try w.enter(io, e); continue; } if (e.kind != .file) continue; files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }); files_len += 1; } } std.mem.sort([]const u8, files[0..files_len], {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } }.lt); const buf = try gpa.alloc(u8, grep_max_bytes); defer gpa.free(buf); for (files[0..files_len]) |path| { if (hits >= find_max_hits or written == out.len) break; var pathbuf: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NONBLOCK = true }); if (fd < 0) continue; var len: usize = 0; var readable = true; while (len < buf.len) { const n = libc.read(fd, buf[len..].ptr, buf.len - len); if (n < 0) { if (libc.errno(n) == .INTR) continue; readable = false; break; } if (n == 0) break; len += @intCast(n); } _ = libc.close(fd); if (!readable) continue; const text = buf[0..len]; if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; const shown = lsp.rel(home, path); const result = grepText(shown, text, pat, out[written..], find_max_hits - hits); hits += result.hits; written += result.bytes; } return written; } test "grep skips a file it cannot read instead of abandoning the search" { if (!platform_has_fs) return; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var base_buf: [std.fs.max_path_bytes]u8 = undefined; const dir = base_buf[0..try tmp.dir.realPath(std.testing.io, &base_buf)]; try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a-locked.txt", .data = "needle here\n" }); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "b-open.txt", .data = "needle here\n" }); var locked_buf: [std.fs.max_path_bytes]u8 = undefined; const locked = try std.fmt.bufPrintSentinel(&locked_buf, "{s}/a-locked.txt", .{dir}, 0); if (libc.chmod(locked, 0) != 0) return; const probe = libc.open(locked, .{ .ACCMODE = .RDONLY }); if (probe >= 0) { _ = libc.close(probe); _ = libc.chmod(locked, 0o644); return error.SkipZigTest; } var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const out = try gpa.alloc(u8, 64 * 1024); defer gpa.free(out); const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out); _ = libc.chmod(locked, 0o644); // so `tmp.cleanup` can remove it try std.testing.expect(std.mem.indexOf(u8, out[0..n], "b-open.txt") != null); try std.testing.expect(std.mem.indexOf(u8, out[0..n], "a-locked.txt") == null); } fn isDir(path: [*:0]const u8) bool { const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); if (fd < 0) return false; _ = libc.close(fd); return true; } pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { return readFileLimit(gpa, path, limits.max_file_bytes); } /// Restore first tries a relative argument under the default dump directory, /// then preserves the ordinary file lookup. Absolute paths remain verbatim. pub fn readRestore(gpa: std.mem.Allocator, path: []const u8) ![]u8 { var dir_buf: [4096]u8 = undefined; const dir = @import("dump.zig").defaultDirectory(&dir_buf); return readRestoreIn(gpa, path, dir); } fn readRestoreIn(gpa: std.mem.Allocator, path: []const u8, dir: ?[]const u8) ![]u8 { if (!std.fs.path.isAbsolute(path)) { if (dir) |base| { var buf: [4096]u8 = undefined; if (std.fmt.bufPrint(&buf, "{s}/{s}", .{ base, path })) |candidate| { if (readFile(gpa, candidate)) |bytes| return bytes else |err| switch (err) { error.FileNotFound => {}, else => return err, } } else |_| {} } } return readFile(gpa, path); } test "Restore prefers default directory then falls back to original path" { if (!platform_has_fs) return error.SkipZigTest; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const relative = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/a.dump", .{tmp.sub_path}); defer gpa.free(relative); const base = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/dumps", .{tmp.sub_path}); defer gpa.free(base); const nested = try std.fmt.allocPrint(gpa, "dumps/.zig-cache/tmp/{s}", .{tmp.sub_path}); defer gpa.free(nested); try tmp.dir.createDirPath(std.testing.io, nested); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "dumps/a.dump", .data = "short name" }); const short = try readRestoreIn(gpa, "a.dump", base); defer gpa.free(short); try std.testing.expectEqualStrings("short name", short); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a.dump", .data = "original" }); const fallback = try readRestoreIn(gpa, relative, base); defer gpa.free(fallback); try std.testing.expectEqualStrings("original", fallback); const candidate = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ base, relative }); defer gpa.free(candidate); try writeFile(candidate, "default"); const preferred = try readRestoreIn(gpa, relative, base); defer gpa.free(preferred); try std.testing.expectEqualStrings("default", preferred); const absolute = try tmp.dir.realPathFileAlloc(std.testing.io, "a.dump", gpa); defer gpa.free(absolute); const direct = try readRestoreIn(gpa, absolute, base); defer gpa.free(direct); try std.testing.expectEqualStrings("original", direct); try std.testing.expectError(error.FileNotFound, readRestoreIn(gpa, "missing-restore-test.dump", base)); } fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; if (!platform_has_fs or std.mem.startsWith(u8, path, "/virtual/")) { const archive_path = if (std.mem.startsWith(u8, path, "/virtual/")) path[9..] else path; var normalized_buf: [4096]u8 = undefined; const normalized = normalizeVirtualPath(archive_path, &normalized_buf) orelse return error.OpenFailed; const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed; if (source.contents.len > limit) return error.FileTooLarge; return gpa.dupe(u8, source.contents); } var pathbuf: [4096]u8 = undefined; const native = localPath(path) orelse return error.OpenFailed; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{native}, 0) catch return error.PathTooLong; const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .NONBLOCK = true }); if (fd < 0) return switch (libc.errno(fd)) { .ACCES, .PERM => error.PermissionDenied, .NOENT => error.FileNotFound, .ISDIR => error.IsDirectory, .NAMETOOLONG => error.PathTooLong, else => error.OpenFailed, }; defer _ = libc.close(fd); const end = libc.lseek(fd, 0, libc.SEEK.END); if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; const size: usize = if (end < 0) 0 else @intCast(end); if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; if (size == 0) { const max_bytes = @min(limit, limits.max_stream_bytes); var stream: std.Io.Writer.Allocating = .init(gpa); errdefer stream.deinit(); var chunk: [16 * 1024]u8 = undefined; while (true) { const n = libc.read(fd, &chunk, @min(chunk.len, max_bytes - stream.written().len + 1)); if (n < 0) { if (libc.errno(n) == .INTR) continue; if (libc.errno(n) == .AGAIN) { if (stream.written().len == 0) return error.NotAFile; return stream.toOwnedSlice(); } return error.ReadFailed; } if (n == 0) return stream.toOwnedSlice(); const received: usize = @intCast(n); if (received > max_bytes - stream.written().len) return error.FileTooLarge; try stream.writer.writeAll(chunk[0..received]); } } if (size > limit) return error.FileTooLarge; var buf = try gpa.alloc(u8, size); errdefer gpa.free(buf); var len: usize = 0; while (len < buf.len) { const n = libc.read(fd, buf[len..].ptr, buf.len - len); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } if (n == 0) break; len += @intCast(n); } if (len != buf.len) buf = try gpa.realloc(buf, len); return buf; } const Pardes = pardes.Pardes; const Pane = pardes.Pane; const MAX_PANES = pardes.MAX_PANES; pub const namespace_root: u64 = 1 << 63; pub const namespace_panes: u64 = namespace_root + 1; pub const os_root: u64 = namespace_root + 2; pub const os_node: u64 = 1 << 62; const factory_base: u64 = namespace_root + 256; pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { var path_buf: [4096]u8 = undefined; const normalized = normalizeVirtualPath(path, &path_buf) orelse return null; var parts = std.mem.tokenizeScalar(u8, normalized, '/'); const first = parts.next() orelse return @intFromEnum(SelfFile.root); if (!std.mem.eql(u8, first, "pane")) { const top = topFileNamed(first) orelse return archiveNode(normalized); if (parts.next()) |name| { if (top != .new or parts.next() != null) return null; const file = paneFileNamed(name) orelse return null; if (file.inPty()) return null; return factory_base + @intFromEnum(file); } return @intFromEnum(top); } const serial = serialNamed(parts.next() orelse return namespace_panes) 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 = ptyFileNamed(name) orelse return null; if (parts.next() != null) return null; return Node.of(serial, child); } return Node.of(serial, file); } fn namespace(p: *Pardes, req: Req) Reply { switch (req.op) { .getattr => return .{ .tag = req.tag, .attr = .{ .node = req.node, .dir = true, .mode = 0o500 } }, .open => return .{ .tag = req.tag, .handle = 1 }, .release => return .{ .tag = req.tag }, .lookup => { if (std.mem.eql(u8, req.data, "..")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = if (req.node == namespace_root) namespace_root else @intFromEnum(SelfFile.root), }); if (req.node == namespace_root) { if (std.mem.eql(u8, req.data, "self")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) }); if (std.mem.eql(u8, req.data, "os")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = os_root }); } else { const serial = serialNamed(req.data) orelse return Reply.fail(req.tag, E.NOENT); if (serial == 0) return Reply.fail(req.tag, E.NOENT); return handle(p, .{ .tag = req.tag, .op = .getattr, .node = Node.of(serial, .dir) }); } return Reply.fail(req.tag, E.NOENT); }, .readdir => { const out = p.fs.stage(p.gpa); var skip = req.off; if (req.node == namespace_root) { if (skip == 0) stageDirent(out, p.gpa, os_root, true, "os") else skip -= 1; if (skip == 0) stageDirent(out, p.gpa, @intFromEnum(SelfFile.root), true, "self"); } else { 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); } } return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; }, else => return Reply.fail(req.tag, E.PERM), } } 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, .lookup => req.node == @intFromEnum(SelfFile.new) and !std.mem.eql(u8, req.data, ".."), .open => req.node >= factory_base and req.node < factory_base + 16, .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 = 0o600, }; 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 PaneFile = enum(u4) { dir = 0, addr, body, ctl, data, errors, event, tag, xdata, rdsel, wrsel, pty, pty_ctl, pty_status, pty_data, pub fn name(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 => 0o500, .errors, .wrsel, .pty_ctl => 0o200, .rdsel, .pty_status => 0o400, else => 0o600, }; } 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 SelfFile = enum(u4) { root = 1, index = 2, cons = 3, new = 4, screen = 5, listeners = 6, pub fn name(f: SelfFile) []const u8 { return if (f == .root) "." else @tagName(f); } pub fn mode(f: SelfFile) u16 { return switch (f) { .root, .new => 0o500, .index, .screen, .listeners => 0o400, .cons => 0o200, }; } pub fn dir(f: SelfFile) bool { return f == .root or f == .new; } }; 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(SelfFile, 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: SelfFile, pane: struct { serial: u32, file: PaneFile }, }; pub const out_reserve = 4 * 1024; pub const queue_cap = 64 * 1024; pub const Queue = struct { buf: std.ArrayList(u8) = .empty, 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; }; } 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; } } 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; } pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { q.buf.clearAndFree(gpa); q.head = 0; } }; pub const PaneState = struct { addr: Range = .{}, limit: ?Range = null, readers: u16 = 0, events: Queue = .{}, nomark: bool = false, noscroll: bool = false, tag_snap: std.ArrayList(u8) = .empty, pty_readers: u16 = 0, pty_out: Queue = .{}, pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; fn deinit(pf: *PaneState, gpa: std.mem.Allocator) void { pf.events.deinit(gpa); pf.pty_out.deinit(gpa); pf.tag_snap.deinit(gpa); pf.* = .{}; } }; pub const Namespace = struct { socket_path: []const u8 = "", tcp_address: ?std.Io.net.IpAddress = null, quic_address: ?std.Io.net.IpAddress = null, mounts: std.ArrayList(Mount) = .empty, node_name: [16]u8 = undefined, os_paths: std.ArrayList(OsPath) = .empty, snapshots: [32]struct { node: u64 = 0, bytes: ?[]const u8 = null } = @splat(.{}), out: std.ArrayList(u8) = .empty, panes: [MAX_PANES]PaneState = @splat(.{}), listeners: u16 = 0, origin: u8 = 'K', pub fn deinit(st: *Namespace, gpa: std.mem.Allocator) void { for (st.mounts.items) |entry| { gpa.free(entry.name); gpa.free(entry.dial); } st.mounts.deinit(gpa); for (st.os_paths.items) |entry| gpa.free(entry.path); st.os_paths.deinit(gpa); for (st.snapshots) |snapshot| if (snapshot.bytes) |bytes| gpa.free(bytes); for (&st.panes) |*pf| pf.deinit(gpa); st.out.deinit(gpa); } pub fn mount(st: *Namespace, gpa: std.mem.Allocator, name: []const u8, dial: []const u8) !void { if (!validMountName(name)) return error.BadMountName; if (comptime pardes.hosted) try ninep_io.Client.validateDial(dial); if (dial.len == 0 or std.mem.indexOfScalar(u8, dial, 0) != null) return error.BadDial; for (st.mounts.items) |entry| if (std.mem.eql(u8, name, entry.name)) return error.AlreadyMounted; if (st.mounts.items.len == max_mounts) return error.TooManyMounts; const saved_name = try gpa.dupe(u8, name); errdefer gpa.free(saved_name); const saved_dial = try gpa.dupe(u8, dial); errdefer gpa.free(saved_dial); try st.mounts.append(gpa, .{ .name = saved_name, .dial = saved_dial }); } pub fn stage(st: *Namespace, gpa: std.mem.Allocator) *std.ArrayList(u8) { st.out.clearRetainingCapacity(); st.out.ensureTotalCapacity(gpa, out_reserve) catch {}; return &st.out; } pub fn forget(st: *Namespace, 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); } pub fn scripted(st: *const Namespace, id: usize) bool { return id < MAX_PANES and st.panes[id].readers != 0; } }; pub fn unmount(p: *Pardes, name: []const u8) !void { for (p.fs.mounts.items, 0..) |entry, index| { if (!std.mem.eql(u8, name, entry.name)) continue; var prefix_buf: [name_capacity + 4]u8 = undefined; const prefix = try std.fmt.bufPrint(&prefix_buf, "/n/{s}", .{name}); for (p.panes) |slot| { const pane = slot orelse continue; const paths = [_][]const u8{ if (pane.file) |file| file.path else "", if (comptime pardes.pdf_enabled) (if (pane.pdf) |pdf| pdf.path else "") else "", if (pane.image) |image| image.path else "", Pardes.paneDir(pane), }; for (paths) |path| if (std.mem.startsWith(u8, path, prefix) and (path.len == prefix.len or path[prefix.len] == '/')) return error.MountInUse; } for (0..p.effects_len) |i| { const effect = p.effects[(p.effects_head + i) % p.effects.len]; if (effect != .save_text) continue; const path = effect.save_text.path.slice(); if (std.mem.startsWith(u8, path, prefix) and (path.len == prefix.len or path[prefix.len] == '/')) return error.MountInUse; } const removed = p.fs.mounts.orderedRemove(index); p.gpa.free(removed.name); p.gpa.free(removed.dial); return; } return error.NotMounted; } pub const max_record_text = 256; 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', pub fn char(a: Action) u8 { return @intFromEnum(a); } pub fn fromChar(c: u8) ?Action { return std.enums.fromInt(Action, c); } pub fn onTag(a: Action) bool { return @intFromEnum(a) >= 'a'; } }; pub const flag_builtin: u32 = 1; pub const flag_expansion: u32 = 2; pub const flag_filename: u32 = 4; pub const flag_chorded: u32 = 8; 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]; } 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), }; } 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], ); } 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; } 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; 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; } } pub fn handle(p: *Pardes, req: Req) Reply { if (req.node >= factory_base and req.node < factory_base + 16) { const file = std.enums.fromInt(PaneFile, req.node - factory_base) orelse return Reply.fail(req.tag, E.NOENT); if (req.op == .getattr) return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } }; if (req.op != .open) return Reply.fail(req.tag, E.PERM); const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); const node = Node.of(serial, file); var reply = handle(p, .{ .tag = req.tag, .op = .open, .node = node }); reply.attr.node = node; return reply; } if (req.node == namespace_root or req.node == namespace_panes) return namespace(p, req); if (req.node == os_root or req.node & os_node != 0) return osHandle(p, req); if (req.node & archive_node != 0) return archiveHandle(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 => 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 => handleRead(p, req, target), .write => handleWrite(p, req, target), }; } const AttrResult = union(enum) { ok: Reply.Attr, missing }; fn attrOf(p: *Pardes, target: Target) AttrResult { switch (target) { .top => |f| return .{ .ok = .{ .name = if (f == .root) "self" else f.name(), .node = @intFromEnum(f), .dir = f.dir(), .mode = f.mode(), .size = topSize(p, f), } }, .pane => |t| { const id = p.paneBySerial(t.serial) orelse return .missing; if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing; return .{ .ok = .{ .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.name(), .node = Node.of(t.serial, t.file), .dir = t.file.isDir(), .mode = t.file.mode(), .size = paneFileSize(p, id, t.file), } }; }, } } fn topSize(p: *Pardes, f: SelfFile) u64 { return switch (f) { .root, .new, .cons, .screen, .listeners => 0, .index => indexLen(p), }; } fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pane = p.panes[id] orelse return 0; return switch (f) { .body, .data, .xdata => bodyLen(p, pane), .tag => tagLen(p, pane), .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0, .pty, .pty_ctl, .pty_status, .pty_data => 0, }; } fn bodyOf(pane: *const Pane) []const u8 { if (pane.file) |*f| return f.content; return ""; } fn fileOf(pane: *Pane) ?*panes.File.State { return if (pane.file) |*f| f else null; } fn tagOf(p: *Pardes, pane: *Pane) []const u8 { return p.tagText(p.scratch.allocator(), pane) catch ""; } 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 "/"; } fn dirtyOf(pane: *const Pane) bool { const f = if (pane.file) |*x| x else return false; if (!panes.Output.fileTraits(f.output).saves) return false; return f.revision != f.saved_revision; } 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]; } fn dotOf(pane: *Pane) PaneState.Range { const text = bodyOf(pane); const head = modal.offsetAt(text, cellOf(pane.cur_row, pane.cur_col)); if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; const anchor = modal.offsetAt(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) }; } fn setDot(pane: *Pane, r: PaneState.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.positionAt(text, q0); pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; const h = modal.positionAt(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(); } fn showOffset(pane: *Pane, off: usize) void { const text = bodyOf(pane); const c = modal.positionAt(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(); } fn clampAddr(pf: *PaneState, 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); } } fn shiftBy(r: PaneState.Range, at: u32, removed: u32, inserted: u32) PaneState.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; } 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; if (c & 0xC0 == 0xC0) { const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len; if (i + need <= data.len or i == 0) return data.len; return i; } } return data.len; } 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 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) ?SelfFile { const f = std.meta.stringToEnum(SelfFile, 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; return std.fmt.parseInt(u32, name, 10) catch null; } 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; } 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 (std.mem.eql(u8, name, "..")) { const parent: u64 = switch (target) { .top => |top| if (top == .root) namespace_root else @intFromEnum(SelfFile.root), .pane => |t| if (t.file == .pty) Node.of(t.serial, .dir) else namespace_panes, }; return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent }); } 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 (std.mem.eql(u8, name, "pane")) return .{ .tag = req.tag, .attr = .{ .node = namespace_panes, .dir = true, .mode = 0o500 } }; if (topFileNamed(name)) |t| break :root @intFromEnum(t); if (archiveNode(name)) |node| return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); return Reply.fail(req.tag, E.NOENT); }, .new => new: { const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT); 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); 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); }, }; 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), }; } 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; } 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.name()); } } } 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 (.{ SelfFile.index, SelfFile.cons, SelfFile.new }) |t| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.name()); } if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, namespace_panes, true, "pane"); if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.screen), false, "screen"); if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.listeners), false, "listeners"); stageArchive(p, out, "", &skip); }, .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), .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 open(p: *Pardes, req: Req, target: Target) Reply { const snapshot = switch (target) { .top => |f| f == .screen, .pane => |t| t.file == .body and if (p.paneBySerial(t.serial)) |id| p.panes[id].?.isTerminal() else false, }; if (snapshot) { for (&p.fs.snapshots, 0..) |*slot, i| { if (slot.node != 0) continue; const bytes = if (target == .top) screenSnapshot(p) catch return Reply.fail(req.tag, E.NOMEM) else null; slot.* = .{ .node = req.node, .bytes = bytes }; return .{ .tag = req.tag, .handle = @intCast(i + 1) }; } return Reply.fail(req.tag, E.NFILE); } 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]; if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT); switch (t.file) { .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 (req.handle > 0 and req.handle <= p.fs.snapshots.len) { const snapshot = &p.fs.snapshots[req.handle - 1]; if (snapshot.node == req.node) { if (snapshot.bytes) |bytes| p.gpa.free(bytes); snapshot.* = .{}; return .{ .tag = req.tag }; } } 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 }; 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| 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), }; } 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 handleRead(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { .index => readIndex(p, req), .listeners => listeners: { var buf: [512]u8 = undefined; var text = std.Io.Writer.fixed(&buf); if (p.fs.socket_path.len != 0) text.print("unix!{s}\n", .{p.fs.socket_path}) catch break :listeners Reply.fail(req.tag, E.IO); for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| { const address = maybe orelse continue; switch (address) { .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch break :listeners Reply.fail(req.tag, E.IO), .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch break :listeners Reply.fail(req.tag, E.IO), } } const bytes = text.buffered(); const off = @min(req.off, bytes.len); const len = @min(bytes.len - off, req.size); p.fs.stage(p.gpa).appendSlice(p.gpa, bytes[off..][0..len]) catch break :listeners Reply.fail(req.tag, E.NOMEM); break :listeners .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; }, .screen => readSnapshot(p, req, null), .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]; 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), }; }, } } fn screenSnapshot(p: *Pardes) ![]u8 { var arena: std.heap.ArenaAllocator = .init(p.gpa); defer arena.deinit(); const surface = try p.render(arena.allocator()); var styles: std.ArrayList(pardes.CellStyle) = .empty; var indices: std.ArrayList(usize) = .empty; for (surface.cells) |cell| { const style: pardes.CellStyle = if (cell.default) .{} else cell.style; const index = for (styles.items, 0..) |previous, i| { if (std.meta.eql(style, previous)) break i; } else new: { try styles.append(arena.allocator(), style); break :new styles.items.len - 1; }; try indices.append(arena.allocator(), index); } var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); const writer = &out.writer; try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows }); try std.json.Stringify.value(surface.cursor, .{}, writer); try writer.writeAll(",\"styles\":"); try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer); try writer.writeAll(",\"cells\":["); for (surface.cells, indices.items, 0..) |cell, index, i| { if (i != 0) try writer.writeByte(','); try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer); } try writer.writeAll("]}\n"); return out.toOwnedSlice(); } const ctl_fields = 5 * 12; 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, @as(u32, 0), @intFromBool(dirtyOf(pane)), }) catch {}; } 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); } fn readAddr(p: *Pardes, req: Req, pf: *PaneState, 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) { 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), } } }; } if (req.handle != 0 and pane.isTerminal()) return readSnapshot(p, req, pane); const text = panes.Terminal.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); } fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply { if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL); const snapshot = &p.fs.snapshots[req.handle - 1]; if (snapshot.node == 0 or snapshot.node != req.node) return Reply.fail(req.tag, E.INVAL); if (snapshot.bytes == null) { const terminal = pane orelse return Reply.fail(req.tag, E.INVAL); snapshot.bytes = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); } const bytes = snapshot.bytes.?; const off = @min(req.off, bytes.len); const len = @min(bytes.len - off, req.size); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; } fn fontName(p: *Pardes) []const u8 { const name = p.settings.font.effective_name.get(); return if (name.len == 0) "default" else name; } 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 {}; } fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { const out = p.fs.stage(p.gpa); stageCtlNumbers(p, out, pane); 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); } fn readData(req: Req, id: usize, pane: *Pane, pf: *PaneState, stop_at_end: bool) Reply { const text = bodyOf(pane); clampAddr(pf, text.len); const q0: usize = pf.addr.q0; 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)); 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), } } }; } 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), } } }; } fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; 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) } }; } 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); } fn readPtyData(p: *Pardes, req: Req, pf: *PaneState) 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) } }; } fn handleWrite(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { .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), .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), }; }, } } 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..]); if (!p.fs.panes[id].nomark) panes.File.pushUndo(p, pane); panes.File.setContent(p, f, new); return take; } fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; 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) }; } fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; 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) }; } 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); 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) }; } 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) }; } 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); 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) }; } 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; } const Addr = struct { text: []const u8, lim: ?PaneState.Range, expr: []const u8, i: usize = 0, depth: u8 = 0, const max_depth = 32; const Size = enum { char, line }; fn address(a: *Addr, ar_in: PaneState.Range) ?PaneState.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) { ',', ';' => { 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; }, '+', '-' => { 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; }, '.', '$' => { 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; }, } } if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null; return r; } 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]; } fn number(a: *Addr, r_in: PaneState.Range, n: u32, dir: u8, size: Size) ?PaneState.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; 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) }; } fn regexp(a: *Addr, r: PaneState.Range, pat: []const u8, back: bool) ?PaneState.Range { 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) }; } }; const Verb = enum { @"addr=dot", clean, cleartag, del, delete, dirty, @"dot=addr", get, @"limit=addr", mark, nomark, noscroll, put, scroll, show, }; 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] == ' '; } fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { for ([2]bool{ false, true }) |apply| { 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; const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); const errno = ctlVerb(p, live, line, apply, &dirty); if (errno != 0) return Reply.fail(req.tag, errno); } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } /// Change a buffer's absolute name without writing or renaming a disk file. /// Tag editing preserves user commands; the 9P name operation resets its tail. pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !void { const pane = p.panes[id] orelse return error.NoPane; const f = fileOf(pane) orelse return error.NotFile; if (!std.fs.path.isAbsolute(full) or full.len == 0 or full.len >= 4096) return error.InvalidName; for (full) |c| if (c < ' ') return error.InvalidName; if (std.mem.eql(u8, f.path, full)) return; const copy = try p.gpa.dupe(u8, full); p.gpa.free(f.path); f.path = copy; if (panes.Output.fileTraits(f.output).saves) { f.output = null; pane.clearCwd(); if (!preserve_tag) { pane.tag_init = false; pane.tag_tail_len = 0; } f.saved_revision = f.revision -% 1; f.watch_after_save = localPath(full) != null; } if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); f.highlights = &.{}; f.highlight_start = 0; f.syntax_dirty = true; p.invalidateLookHover(id); p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); } fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) u16 { const pane = p.panes[id] orelse return E.INVAL; const pf = &p.fs.panes[id]; if (verbIs(line, "look")) { if (line.len <= 5) return E.INVAL; const word = std.mem.trim(u8, line[5..], " \t"); if (word.len == 0) return E.INVAL; for (word) |c| if (c < ' ') return E.INVAL; if (apply) p.lookAt(id, word); return 0; } if (verbIs(line, "name")) { if (line.len <= 5) return E.INVAL; const name = std.mem.trim(u8, line[5..], " \t"); if (name.len == 0) return E.INVAL; for (name) |c| if (c <= ' ') return E.INVAL; const f = fileOf(pane) orelse return 0; const full = std.fs.path.resolvePosix(p.gpa, &.{ Pardes.paneDir(pane), name }) catch return E.NOMEM; defer p.gpa.free(full); if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return E.INVAL; if (std.mem.eql(u8, f.path, full)) return 0; if (panes.Output.fileTraits(f.output).saves) dirty.* = true; if (!apply) return 0; nameBuffer(p, id, full, false) catch return E.NOMEM; return 0; } for (refused_verbs) |w| if (verbIs(line, w)) return E.INVAL; const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL; if (v == .del and dirty.*) return E.INVAL; switch (v) { .dirty => dirty.* = true, .clean, .get, .put => dirty.* = false, else => {}, } if (!apply) return 0; 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; }, .clean => if (fileOf(pane)) |f| { f.saved_revision = f.revision; }, .dirty => if (fileOf(pane)) |f| { f.saved_revision = f.revision -% 1; }, .cleartag => { pane.tag_tail_len = 0; pane.tag_init = true; }, .del, .delete => p.removePane(id) catch return E.NOMEM, .put => { const serial = pane.serial; if (fileOf(pane)) |f| { if (f.output == null) { p.perform(.{ .save_file = .{ .pane = @intCast(id) } }); const current = p.paneBySerial(serial) orelse return E.NOENT; if (dirtyOf(p.panes[current].?)) return E.IO; } else _ = p.executeBuiltinLine(id, "Save"); } else _ = p.executeBuiltinLine(id, "Save"); }, .get => if (fileOf(pane)) |f| { if (panes.Output.fileTraits(f.output).saves) { if (read(p, f.path)) |bytes| { panes.File.pushUndo(p, pane); panes.File.setContent(p, f, bytes); f.saved_revision = f.revision; } else |err| return switch (err) { error.FileNotFound => E.NOENT, else => E.IO, }; } }, .mark => { pf.nomark = false; panes.File.pushUndo(p, pane); }, .nomark => pf.nomark = true, .noscroll => pf.noscroll = true, .scroll => pf.noscroll = false, .show => showOffset(pane, dotOf(pane).q0), } return 0; } const PtyVerb = enum { winsize, sig, exec }; 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; } 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; } 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) }; } 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"); const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false; switch (v) { .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 } }); }, .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 } }); }, .exec => { if (words.next() != null) return false; if (pane.cwdSlice().len > pardes.effect_path_cap) return false; if (!apply) return true; p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); }, } return true; } 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) }; } const EventRecord = struct { action: Action, q0: u32, q1: u32 }; 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; 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); } 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) { .body_look, .tag_look, .body_exec, .tag_exec => {}, else => return Reply.fail(req.tag, E.INVAL), } 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); } var run: EventReader = .{ .data = req.data }; while (run.next()) |r| { 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)); 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) }; } 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 = panes.Output.open(p, free, dir, .errors, "", content) catch { p.gpa.free(content); return null; }; p.placeDoc(id, free, np); return text.len; } 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; } fn bodyLen(p: *Pardes, pane: *Pane) u64 { _ = p; return bodyOf(pane).len; } fn tagLen(p: *Pardes, pane: *Pane) u64 { return tagOf(p, pane).len; } test "filesystem inspection preserves pending and displayed Look hover" { const delay = config.look_preview_delay_frames orelse return; const gpa = std.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 = @intFromEnum(SelfFile.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 = @intFromEnum(SelfFile.root), .data = "screen" }, .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = ".." }, .{ .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 = @intFromEnum(SelfFile.root), .size = 4096 }, .{ .tag = 7, .op = .open, .node = screen }, .{ .tag = 8, .op = .read, .node = screen, .handle = 1, .size = 32 }, .{ .tag = 9, .op = .release, .node = screen, .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 = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" }, .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }, .{ .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 "terminal body handles keep one history snapshot across fragmented reads" { const gpa = std.testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const node = Node.of(serialOf(p), .body); p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } }); while (p.nextEffect()) |_| {} const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa); defer gpa.free(original); const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqual(Status.ok, opened.reply.status); const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 }); try testing.expectEqualStrings(original[0..3], first.bytes); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } }); while (p.nextEffect()) |_| {} var offset: usize = 3; while (offset < original.len) { const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 }); try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes); offset += part.bytes.len; } try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes); _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle }); const newer = call(p, .{ .tag = 6, .op = .open, .node = node }); try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle }); } test "an empty terminal body snapshot stays empty while output continues" { const p = try withTerm(std.testing.allocator); defer p.deinit(); const node = Node.of(serialOf(p), .body); const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); try testing.expect(p.fs.snapshots[opened.reply.handle - 1].bytes != null); p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } }); while (p.nextEffect()) |_| {} try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); const newer = call(p, .{ .tag = 5, .op = .open, .node = node }); try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle }); } test "terminal body snapshots are lazy bounded and released after the pane closes" { const gpa = std.testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const node = Node.of(serialOf(p), .body); var handles: [32]u32 = undefined; for (&handles) |*opened_handle| { const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqual(Status.ok, opened.reply.status); opened_handle.* = opened.reply.handle; } for (p.fs.snapshots) |snapshot| { try testing.expectEqual(node, snapshot.node); try testing.expect(snapshot.bytes == null); } try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno()); const scratch = call(p, .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }); try testing.expectEqual(Status.ok, scratch.reply.status); const scratch_id = p.paneBySerial(Node.target(scratch.reply.attr.node).?.pane.serial).?; try testing.expect(p.panes[scratch_id].?.file != null); _ = call(p, .{ .tag = 2, .op = .release, .node = scratch.reply.attr.node, .handle = scratch.reply.handle }); p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } }); while (p.nextEffect()) |_| {} var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); p.gpa = failing.allocator(); const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 }); p.gpa = gpa; try testing.expectEqual(E.NOMEM, failed.errno()); try testing.expect(p.fs.snapshots[handles[0] - 1].bytes == null); const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 }); try testing.expectEqualStrings("latest output", first.bytes); const saved = p.fs.snapshots[handles[0] - 1].bytes.?; failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); p.gpa = failing.allocator(); const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 }); p.gpa = gpa; try testing.expectEqualStrings("output", second.bytes); try testing.expect(!failing.has_induced_failure); try testing.expectEqual(saved.ptr, p.fs.snapshots[handles[0] - 1].bytes.?.ptr); const screen = @intFromEnum(SelfFile.screen); try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen, .handle = handles[0], .size = 32 }).errno()); _ = call(p, .{ .tag = 7, .op = .release, .node = screen, .handle = handles[0] }); try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node); try p.removePane(0); for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle }); for (p.fs.snapshots) |snapshot| { try testing.expectEqual(@as(u64, 0), snapshot.node); try testing.expect(snapshot.bytes == null); } } test "screen snapshots preserve rendered cells and styles until their handle is released" { const gpa = std.testing.allocator; const p = try withFile(gpa, "const value = 1;\n"); defer p.deinit(); const node = @intFromEnum(SelfFile.screen); const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqual(Status.ok, opened.reply.status); const snapshot = p.fs.snapshots[opened.reply.handle - 1].bytes.?; const original = try gpa.dupe(u8, snapshot); defer gpa.free(original); const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{}); defer parsed.deinit(); const data = parsed.value.object; try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer); try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer); try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len); try testing.expect(data.get("styles").?.array.items.len > 0); _ = wr(p, Node.of(serialOf(p), .body), "changed\n"); const newer = call(p, .{ .tag = 2, .op = .open, .node = node }); try testing.expectEqual(Status.ok, newer.reply.status); try testing.expect(!std.mem.eql(u8, original, p.fs.snapshots[newer.reply.handle - 1].bytes.?)); var off: usize = 0; while (off < original.len) { const result = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = off, .size = 13 }); try testing.expectEqual(Status.ok, result.reply.status); try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes); off += result.bytes.len; } _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = newer.reply.handle }); for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0); } test "screen inspection preserves acknowledged presentation and the next real frame" { const gpa = testing.allocator; for (std.enums.values(pardes.layout.Transition)) |transition| { errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)}); const control = try withFile(gpa, "const value = 1;\n"); defer control.deinit(); const inspected = try withFile(gpa, "const value = 1;\n"); defer inspected.deinit(); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); for ([_]*Pardes{ control, inspected }) |p| { p.settings.panel_transition = .off; p.update(.tick); p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); p.settings.panel_transition = transition; } for (0..4) |phase| { for ([_]*Pardes{ control, inspected }) |p| switch (phase) { 0 => {}, 1 => p.update(.{ .command = "New" }), 2 => { p.update(.tick); try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status); }, 3 => { for (0..transition.frames() + 1) |_| p.update(.tick); p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); p.update(.{ .command = "Del" }); }, else => unreachable, }; const screen = @intFromEnum(SelfFile.screen); for (0..2) |_| { const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen }); try testing.expectEqual(Status.ok, opened.reply.status); const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen, .handle = opened.reply.handle, .size = 32 }); try testing.expectEqual(Status.ok, captured.reply.status); try testing.expect(captured.bytes.len > 0); try testing.expectEqual(Status.ok, call(inspected, .{ .tag = 3, .op = .release, .node = screen, .handle = opened.reply.handle, }).reply.status); } const a = &control.presentation; const b = &inspected.presentation; try testing.expectEqualDeep(a.shown, b.shown); try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks); try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]); try testing.expectEqual(a.pending, b.pending); try testing.expectEqual(a.acknowledged, b.acknowledged); try testing.expectEqual(a.previous_valid, b.previous_valid); try testing.expectEqual(a.previous_cols, b.previous_cols); try testing.expectEqual(a.previous_rows, b.previous_rows); try testing.expectEqualDeep(a.previous_layout, b.previous_layout); try testing.expectEqual(a.previous_cells.len, b.previous_cells.len); for (a.previous_cells, b.previous_cells) |*expected, *actual| try testing.expect(expected.visuallyEqual(actual)); const expected = try control.render(arena.allocator()); const actual = try inspected.render(arena.allocator()); try testing.expectEqual(expected.cols, actual.cols); try testing.expectEqual(expected.rows, actual.rows); try testing.expectEqualDeep(expected.cursor, actual.cursor); try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks()); try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs); try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len); try testing.expectEqual(expected.cells.len, actual.cells.len); try testing.expectEqual(@as(usize, 0), expected.nimages); try testing.expectEqual(@as(usize, 0), actual.nimages); for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0); if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff()); if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0); control.acknowledgePanelPresentation(expected.panelTracks()); inspected.acknowledgePanelPresentation(actual.panelTracks()); try testing.expectEqualDeep(a.shown, b.shown); try testing.expectEqualDeep(a.previous_layout, b.previous_layout); _ = arena.reset(.retain_capacity); } } } const testing = std.testing; const Answer = struct { reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO }, bytes: []const u8 = "", saved: bool = false, watch: ?bool = null, pty_buf: [256]u8 = undefined, pty_len: usize = 0, 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; } }; 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, .watch => |w| ans.watch = w.on, .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 }); } 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.setTestFile(text); while (p.nextEffect()) |_| {} return p; } 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 }; 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 "listener addresses are readable canonical dials with bounded partial reads" { const gpa = testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); p.fs.socket_path = "/tmp/a socket.sock"; p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; p.fs.quic_address = .{ .ip6 = .loopback(5641) }; const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n"; for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| { const bytes = try read(p, path); defer gpa.free(bytes); try testing.expectEqualStrings(expected, bytes); try testing.expectError(error.ReadOnlyFilesystem, write(p, path, "")); } const node = @intFromEnum(SelfFile.listeners); var off: usize = 0; while (off < expected.len) { const part = rd(p, node, off, 3); try testing.expectEqual(Status.ok, part.reply.status); try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes); off += part.bytes.len; } try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len); } 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(SelfFile.root), 0); try testing.expectEqual(Status.ok, root.reply.status); const top = dirents(root.bytes, &buf); try testing.expect(top.len >= 5); 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); try testing.expectEqualStrings("pane", top[3].name); try testing.expect(top[3].dir); try testing.expectEqual(namespace_panes, top[3].node); var idbuf: [16]u8 = undefined; try testing.expect(nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); try testing.expect(nameAt(top, "src") != null); const rest = rdir(p, @intFromEnum(SelfFile.root), 3); try testing.expectEqual(top.len - 3, dirents(rest.bytes, &buf).len); const eof = rdir(p, @intFromEnum(SelfFile.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); 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); const before = p.next_serial; const new = rdir(p, @intFromEnum(SelfFile.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); 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(SelfFile.root); try testing.expectEqual(@as(u64, @intFromEnum(SelfFile.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 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, namespace_panes, serial_name); try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); try testing.expect(dir.reply.attr.dir); try testing.expectEqual(E.NOENT, look_up(p, namespace_panes, "99999").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.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); try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "bogus").errno()); try testing.expectEqual(before, p.next_serial); const a = look_up(p, @intFromEnum(SelfFile.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); _ = 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(SelfFile.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 = try gpa.dupe(u8, tagOf(p, pane)); defer gpa.free(tag); 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); try testing.expectEqual(@as(usize, 60), std.mem.indexOf(u8, line, firstLine(tag)).?); const mid = rd(p, @intFromEnum(SelfFile.index), 60, 5); try testing.expectEqualStrings(firstLine(tag)[0..5], mid.bytes); pane.file.?.saved_revision = pane.file.?.revision -% 1; const dirty = rd(p, @intFromEnum(SelfFile.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); var quoted: std.ArrayList(u8) = .empty; defer quoted.deinit(gpa); stageQuoted("ed, gpa, "DejaVu Sans Mono"); try testing.expectEqualStrings("'DejaVu Sans Mono'", quoted.items); quoted.clearRetainingCapacity(); stageQuoted("ed, gpa, "it's"); try testing.expectEqualStrings("'it''s'", quoted.items); } test "body reads at any offset and writes append" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const body = Node.of(serial, .body); try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes); try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes); try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes); try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes); try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr); 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); 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()); 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()); 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, "/test.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")); 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"); 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 }, .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, }) |c| { _ = 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, ); } _ = 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); _ = 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); _ = 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"); _ = 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); for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { _ = wr(p, addr, "#0"); try testing.expectEqual(E.INVAL, wr(p, addr, bad).errno()); } 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); try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q0); try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); _ = 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); _ = 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"); defer p.deinit(); const serial = serialOf(p); _ = wr(p, Node.of(serial, .addr), "#0"); 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); _ = 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); _ = 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); _ = 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]; 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); 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); try testing.expectEqual(Status.ok, wr(p, ctl, "put\n").reply.status); try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); 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()); try testing.expect(!dirtyOf(pane)); try testing.expectEqual(E.INVAL, wr(p, ctl, "dirty\nbogus\n").errno()); try testing.expect(!dirtyOf(pane)); } test "ctl look opens spaced paths and locations without editing the source pane" { if (!platform_has_fs) return error.SkipZigTest; const gpa = testing.allocator; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" }); var path_buf: [4096]u8 = undefined; const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)]; const p = try withFile(gpa, "source stays intact\n"); defer p.deinit(); const source = p.panes[0].?; const ctl = Node.of(source.serial, .ctl); const revision = source.file.?.revision; const undo_len = source.file.?.history.undo_len; source.cur_col = 3; var command: [4200]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status); const opened = p.panes[p.active].?; try testing.expect(opened != source); try testing.expectEqualStrings(path, opened.file.?.path); try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content); try testing.expectEqual(@as(i32, 1), opened.cur_row); try testing.expectEqual(@as(i32, 2), opened.cur_col); try testing.expectEqualStrings("source stays intact\n", source.file.?.content); try testing.expectEqual(revision, source.file.?.revision); try testing.expectEqual(undo_len, source.file.?.history.undo_len); try testing.expectEqual(@as(i32, 3), source.cur_col); try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status); try testing.expect(p.panes[p.active].? == opened); try testing.expectEqual(@as(i32, 2), opened.cur_row); try testing.expectEqual(@as(i32, 1), opened.cur_col); } test "ctl look validates the whole batch before opening virtual files" { const gpa = testing.allocator; const p = try withFile(gpa, "source\n"); defer p.deinit(); const source = p.panes[0].?; const ctl = Node.of(source.serial, .ctl); const serial = p.next_serial; for ([_][]const u8{ "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", }) |invalid| { try testing.expectEqual(E.INVAL, wr(p, ctl, invalid).errno()); try testing.expectEqual(serial, p.next_serial); try testing.expectEqual(@as(usize, 0), p.active); try testing.expectEqualStrings("source\n", source.file.?.content); } try testing.expectEqual(Status.ok, wr(p, ctl, "look /n/self/index\n").reply.status); const opened = p.panes[p.active].?; try testing.expectEqualStrings("/virtual/index", opened.file.?.path); try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); try testing.expectEqualStrings("source\n", source.file.?.content); } test "ctl name promotes a scratch without changing its body or undo history" { const gpa = testing.allocator; const p = try withFile(gpa, "opener\n"); defer p.deinit(); _ = look_up(p, @intFromEnum(SelfFile.new), "ctl"); const pane = p.panes[p.active].?; const ctl = Node.of(pane.serial, .ctl); const body = Node.of(pane.serial, .body); _ = wr(p, body, "scratch "); _ = wr(p, body, "work\n"); const undo_len = pane.file.?.history.undo_len; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "renamed.zig", .data = "existing target\n" }); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; var path_buffer: [4096]u8 = undefined; const path = try std.fmt.bufPrint(&path_buffer, "{s}/renamed.zig", .{directory}); var command: [4104]u8 = undefined; const renamed = wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}\n", .{path})); try testing.expectEqual(Status.ok, renamed.reply.status); try testing.expectEqual(false, renamed.watch.?); try testing.expectEqualStrings("scratch work\n", pane.file.?.content); try testing.expectEqual(undo_len, pane.file.?.history.undo_len); try testing.expect(pane.file.?.output == null and dirtyOf(pane)); try testing.expect(pane.file.?.watch_after_save); const target = try readFile(gpa, path); defer gpa.free(target); try testing.expectEqualStrings("existing target\n", target); const saved = wr(p, ctl, "put\n"); try testing.expectEqual(Status.ok, saved.reply.status); try testing.expectEqual(true, saved.watch.?); try testing.expect(!dirtyOf(pane) and !pane.file.?.watch_after_save); try testing.expectEqualStrings("scratch work\n", p.fallback.get(path).?); } test "ctl name refreshes cached syntax for unchanged contents" { if (!pardes.syntax.enabled) return error.SkipZigTest; pardes.syntax.start(testing.allocator); defer pardes.syntax.stop(); const p = try withFile(testing.allocator, "fn check() void {}\n"); defer p.deinit(); const pane = p.panes[0].?; panes.File.refreshHighlights(p); try testing.expectEqual(@as(usize, 0), pane.file.?.highlights.len); try testing.expectEqual(Status.ok, wr(p, Node.of(pane.serial, .ctl), "name renamed.zig\n").reply.status); panes.File.refreshHighlights(p); try testing.expect(pane.file.?.highlights.len >= 2); try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[0]); try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[1]); try testing.expectEqualStrings("fn check() void {}\n", pane.file.?.content); } test "ctl relative names use the file directory and can name a new target" { const gpa = testing.allocator; const p = try withFile(gpa, "retained body\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from pane directory\n" }); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; const pane = p.panes[0].?; const ctl = Node.of(pane.serial, .ctl); var command: [4140]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}/old.txt\n", .{directory})).reply.status); try testing.expectEqual(Status.ok, wr(p, ctl, "name child/../note.txt\nget\n").reply.status); try testing.expectEqualStrings("from pane directory\n", pane.file.?.content); try testing.expectEqualStrings(directory, Pardes.paneDir(pane)); try testing.expectEqual(Status.ok, wr(p, ctl, "name ./created.txt\nput\n").reply.status); try testing.expect(!dirtyOf(pane)); var expected_buf: [4096]u8 = undefined; const expected = try std.fmt.bufPrint(&expected_buf, "{s}/created.txt", .{directory}); try testing.expectEqualStrings(expected, pane.file.?.path); try testing.expectEqualStrings(pane.file.?.content, p.fallback.get(expected).?); } test "ctl relative names follow inherited scratch and virtual directories" { const p = try withFile(testing.allocator, "source body\n"); defer p.deinit(); const source = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .ctl), "name /project/src/source.zig\n").reply.status); _ = look_up(p, @intFromEnum(SelfFile.new), "ctl"); const scratch = p.panes[p.active].?; const ctl = Node.of(scratch.serial, .ctl); try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch)); try testing.expectEqual(Status.ok, wr(p, ctl, "name ../out/./notes.txt\n").reply.status); try testing.expectEqualStrings("/project/out/notes.txt", scratch.file.?.path); try testing.expectEqualStrings("/project/out", Pardes.paneDir(scratch)); var command: [256]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name /virtual/pane/{d}/./body\nget\n", .{source.serial})).reply.status); try testing.expectEqualStrings(source.file.?.content, scratch.file.?.content); const unchanged = wr(p, ctl, "name ./body\n"); try testing.expectEqual(Status.ok, unchanged.reply.status); try testing.expect(unchanged.watch == null); try testing.expect(!dirtyOf(scratch)); try testing.expect(!scratch.file.?.watch_after_save); } 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 directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; var path_buf: [4096]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory}); 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); try testing.expect(!dirtyOf(pane)); try testing.expect(pane.file.?.history.undo_len > 0); _ = wr(p, ctl, "dirty\n"); try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno()); try testing.expect(p.paneBySerial(serial) != null); _ = look_up(p, @intFromEnum(SelfFile.new), "body"); try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status); try testing.expect(p.paneBySerial(serial) == null); } test "ctl get reports missing files without losing dirty contents or applying del" { const p = try withFile(testing.allocator, "unsaved contents\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; var name: [4140]u8 = undefined; const ctl = Node.of(serialOf(p), .ctl); try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}/missing.txt\ndirty\n", .{directory})).reply.status); try testing.expectEqual(E.NOENT, wr(p, ctl, "get\ndel\n").errno()); const pane = p.panes[0].?; try testing.expectEqualStrings("unsaved contents\n", pane.file.?.content); try testing.expect(dirtyOf(pane)); } test "ctl put waits for one host write and stops before del when saving fails" { const Refusing = struct { p: *Pardes, calls: usize = 0, fn writeFile(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void { const self: *@This() = @ptrCast(@alignCast(ctx.?)); self.calls += 1; self.p.saveFailed(id, "save", error.PermissionDenied); } }; const p = try withFile(testing.allocator, "retained contents\n"); defer p.deinit(); var refusing: Refusing = .{ .p = p }; p.host = .{ .ctx = &refusing, .vtable = &.{ .write_file = Refusing.writeFile } }; const serial = serialOf(p); const ctl = Node.of(serial, .ctl); for ([_][]const u8{ "clean\n", "dirty\n" }, 0..) |initial, i| { _ = wr(p, ctl, initial); const result = wr(p, ctl, "put\ndel\n"); try testing.expectEqual(E.IO, result.errno()); try testing.expect(!result.saved); try testing.expectEqual(i + 1, refusing.calls); const pane = p.panes[p.paneBySerial(serial).?].?; try testing.expect(dirtyOf(pane)); try testing.expectEqualStrings("retained contents\n", pane.file.?.content); } } test "self directories walk to their namespace parents" { const p = try withFile(testing.allocator, "contents\n"); defer p.deinit(); const root = @intFromEnum(SelfFile.root); for ([_]struct { node: u64, parent: u64 }{ .{ .node = root, .parent = namespace_root }, .{ .node = @intFromEnum(SelfFile.new), .parent = root }, .{ .node = namespace_panes, .parent = root }, .{ .node = Node.of(serialOf(p), .dir), .parent = namespace_panes }, }) |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); } } 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); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status); _ = wr(p, @intFromEnum(SelfFile.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); _ = wr(p, Node.of(serial, .body), "new text\n"); try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content); 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); _ = 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); 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, ""); 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); _ = 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); 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); } 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); _ = wr(p, Node.of(serial, .body), "three\n"); const body_recs = drainEvents(p, event, &store, &slots); try testing.expect(body_recs.len >= 1); try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); for (body_recs[1..]) |r| { try testing.expectEqual(@as(u8, 'E'), r[0]); try testing.expect(Action.fromChar(r[1]).?.onTag()); } _ = 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); _ = 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)); _ = 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); _ = look_up(p, @intFromEnum(SelfFile.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); try testing.expectEqual(@as(u16, 0), p.fs.listeners); _ = 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); 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].?; 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]); 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]); 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); } _ = 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); 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()); 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); 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); try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); const before = p.next_serial; try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.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); 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); try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); 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()); 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()); 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); 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); 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.?); } const ex = wr(p, ctl, "exec\n"); try testing.expectEqual(Status.ok, ex.reply.status); try testing.expect(ex.spawned); 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.?); 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", "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", "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); } 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); try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); } const FakeTty = struct { taken: bool, const vtable: pardes.Host.VTable = .{ .tty_taken = answer }; fn answer(ctx: ?*anyopaque, pane: u8) bool { _ = pane; const f: *FakeTty = @ptrCast(@alignCast(ctx.?)); return f.taken; } }; test "owned cwd pty exec rejects long paths before applying its batch" { const p = try withTerm(testing.allocator); defer p.deinit(); const pane = p.panes[0].?; const ctl = Node.of(pane.serial, .pty_ctl); var path: [1025]u8 = @splat('d'); path[0] = '/'; p.setCwd(0, &path); for ([_][]const u8{ "exec\n", "winsize 100 30\nexec\n", "sig TERM\nexec\n" }) |command| { const result = wr(p, ctl, command); try testing.expectEqual(E.INVAL, result.errno()); try testing.expect(!result.spawned); try testing.expect(result.signal == null and result.winsize == null); } try testing.expectEqualStrings(&path, pane.cwdSlice()); p.setCwd(0, path[0..pardes.effect_path_cap]); const accepted = wr(p, ctl, "exec\n"); try testing.expectEqual(Status.ok, accepted.reply.status); try testing.expect(accepted.spawned); } test "owned cwd name promotion releases the former directory" { const p = try withTerm(testing.allocator); defer p.deinit(); p.newScratchBelow(0); const id = p.active; const pane = p.panes[id].?; try pane.setOwnedCwd("/old/directory"); const result = wr(p, Node.of(pane.serial, .ctl), "name saved.txt\n"); try testing.expectEqual(Status.ok, result.reply.status); try testing.expect(pane.cwd == .none); try testing.expect(pane.file.?.output == null); try testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); try testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); } 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); try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); 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); 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()); try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); 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); 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); 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); p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); while (p.nextEffect()) |_| {} try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); 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); _ = 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 }); 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); 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); }