summaryrefslogtreecommitdiff
path: root/src/ninep/ctl.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/ctl.zig')
-rw-r--r--src/ninep/ctl.zig506
1 files changed, 467 insertions, 39 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 4f52d661..477a91fe 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -73,6 +73,7 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo
// An exec answers the pane it went to: a command pane it reused, or
// the terminal it typed into.
p.look_missed = false;
+ p.fs.refilled = null;
const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: {
look.lookAt(p, id, text);
break :blk null;
@@ -88,6 +89,9 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo
created = true;
};
if (created) return;
+ // A +Search it filled again (Find and Grep reuse one): that is what it
+ // touched.
+ if (p.fs.refilled) |serial| if (p.paneBySerial(serial) != null) return noteResult(p, serial);
if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial);
noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}
@@ -170,14 +174,16 @@ pub fn completeEnd(p: *Pardes, data: []const u8) usize {
/// A null `serial` clicks at whichever pane is active as each line runs,
/// which is what the root's own look and exec mean; a serial stays put.
pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Reply {
- p.fs.ran_at = 0;
+ // The root's lines run at the keyboard's pane: a refusal before any runs
+ // is that pane's too, in its err record.
+ p.fs.ran_at = if (serial == null) (if (p.panes[p.active]) |pane| pane.serial else 0) else 0;
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var it = Messages.init(p, req.data);
while (it.next()) |text| {
if (text.len == 0) continue;
// Only an Edit block holds newlines (Messages).
- for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return tree.failText(req.tag, E.INVAL, e_control);
+ for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return tree.failText(req.tag, E.INVAL, e_control);
if (exec) if (tooLong(req, text)) |refusal| return refusal;
if (!apply) continue;
const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
@@ -196,7 +202,9 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
-pub const e_too_long = std.fmt.comptimePrint("a command line is at most {d} bytes", .{pardes.exec.command_max});
+// "invalid" is the word 9ns reads as EINVAL (enameToErrno): through a
+// mount the refusal is EINVAL, as the reply's errno is.
+pub const e_too_long = std.fmt.comptimePrint("invalid command line: a command line is at most {d} bytes", .{pardes.exec.command_max});
/// A line an exec would run as a command, over the most a command line may
/// be: refused whole, before anything runs, rather than said on the message
@@ -210,12 +218,22 @@ pub fn tooLong(req: Req, line: []const u8) ?Reply {
return tree.failText(req.tag, E.INVAL, e_too_long);
}
-pub const e_control = "a command line holds no control character but a tab";
+pub const e_control = "invalid command line: it holds a control character (or DEL) other than a tab";
/// Runs one click (`run`) as a 9P write's: a builtin that fails there fails
/// the write, with its words and an err record, and no msg, as a ctl line
/// does (runBuiltin). The refusal, or null.
pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, in_tag: bool) ?Reply {
+ // A builtin that means nothing without its argument, written bare: the
+ // write is refused, EINVAL, as a ctl's is (checkBuiltin), not run to
+ // fail or to ask.
+ if (exec) {
+ const cmd = exec_line.commandText(text);
+ if (std.meta.stringToEnum(Builtin, cmd)) |b| if (builtins.registry.requiresArg(b)) {
+ p.fs.results_len = 0;
+ return refuse(p, req, "wrong #args in control message", cmd);
+ };
+ }
p.fs.capturing = true;
p.fs.failure_len = 0;
defer p.fs.capturing = false;
@@ -225,7 +243,14 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i
p.fs.results_len = 0;
const failure = p.fs.failure[0..p.fs.failure_len];
// A slot or a place refused keeps its errno words (no space: ENOSPC).
- return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure);
+ return tree.failText(req.tag, failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure);
+}
+
+/// The errno a builtin's failure answers with: ENOENT for what is not
+/// there (`no such`, `not found`, the words 9ns reads as ENOENT too), else
+/// EIO.
+fn failureErrno(failure: []const u8) u16 {
+ return if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) E.NOENT else E.IO;
}
pub fn resultsLen(p: *Pardes) u64 {
@@ -344,7 +369,7 @@ fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
}
/// `refuse`, saying which ctl takes the message instead.
-fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply {
+pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply {
const room = p.fs.ename.len -| (why.len + ctl.len + 18);
const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, line[0..@min(line.len, room)], ctl }) catch why;
return tree.failText(req.tag, E.INVAL, text);
@@ -365,7 +390,9 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
// terminal) is known, and says so rather than sound misspelt.
if (config.Runtime.find(word) != null) {
var why: [64]u8 = undefined;
- return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only", .{word}) catch "GUI-only", line);
+ // EINVAL, a request this build cannot take, in words 9ns reads
+ // as that: `invalid`.
+ return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only, invalid here", .{word}) catch "GUI-only, invalid here", line);
}
return refuse(p, req, "unknown control message", line);
};
@@ -387,7 +414,7 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
// `refuse` quotes the line after it, in what is left of 128.
var near: [40]u8 = undefined;
var why: [104]u8 = undefined;
- return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; ThemeSel lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line);
+ return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; Themes lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line);
},
.font => config.Runtime.FontSpec.parse(arg) != null,
else => probe: {
@@ -426,11 +453,13 @@ pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
if (!created) noteResult(p, context);
if (p.fs.refused) return refuse(p, req, e_prompt, line);
if (p.fs.failure_len == 0) return null;
- // An Edit's failure names what failed (the pattern that missed): its
- // line, a program, is not quoted after it.
- const edit = std.mem.startsWith(u8, line, "Edit ") or std.mem.eql(u8, line, "Edit");
- const refusal = if (edit) tree.failText(req.tag, E.IO, p.fs.failure[0..p.fs.failure_len]) else refuse(p, req, p.fs.failure[0..p.fs.failure_len], line);
- return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename };
+ // A line that ran and failed says why in the builtin's own words, which
+ // name what failed (`Mount peer /tmp/s: dial failed: ...`): the line is
+ // not quoted after them, as an exec's is not. Only a line refused before
+ // it runs, as no message at all, quotes it (`unknown control message
+ // "X"`).
+ const failure = p.fs.failure[0..p.fs.failure_len];
+ return tree.failText(req.tag, failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure);
}
const e_prompt = "control message needs its argument";
@@ -753,14 +782,20 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
var said_buf: [320]u8 = undefined;
const said = said_buf[0..said_in_ename.len];
@memcpy(said, said_in_ename);
- const r = refuse(p, req, said, line);
- return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = r.ename };
+ // It ran and was refused: its words, not the line quoted.
+ return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{said}) catch said);
}
- const errno = get(p, pane);
- // Naming the path, in words 9ns reads as the errno.
+ var failed: anyerror = error.Unexpected;
+ const errno = get(p, pane, &failed);
+ // Naming the path, in words 9ns reads as the errno; a peer
+ // out of reach as a look or a Save says it.
if (errno != 0) {
const path = if (pane_files.fileOf(pane)) |f| f.path else "";
- return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], if (errno == E.NOENT) "no such file" else "cannot be read" }) catch "get: no such file");
+ var why_buf: [48]u8 = undefined;
+ const why = if (pardes.Messages.dialReason(failed)) |r|
+ std.fmt.bufPrint(&why_buf, "dial failed: {s}", .{r}) catch "dial failed"
+ else if (errno == E.NOENT) "no such file" else "cannot be read";
+ return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], why }) catch "get: no such file");
}
} else if (std.mem.eql(u8, line, "lock")) {
if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
@@ -826,20 +861,26 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
if (std.hash.Wyhash.hash(0, f.content) == saved) return null;
if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == .get) return null;
pane.discard_warned = .{ .revision = f.revision, .by = .get };
- const said = std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)";
- // Shown; the write's err record logs it, not a msg too.
+ const id = p.paneBySerial(pane.serial).?;
+ // Kept in +Unsaved, as Exit's refusal is; the notice is short. The
+ // write's err record logs it, not a msg too.
+ const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\nget again to discard\n", .{f.path}) catch null;
+ if (listed) |content| panes.Output.openUnsaved(p, id, content);
p.fs.unlogged = true;
- defer p.fs.unlogged = false;
- p.setMessage(p.paneBySerial(pane.serial).?, said);
- return said;
+ p.setMessage(id, "1 unsaved pane \u{2014} get again to discard");
+ p.fs.unlogged = false;
+ return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)";
}
-fn get(p: *Pardes, pane: *Pane) u16 {
+fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 {
const f = pane_files.fileOf(pane) orelse return 0;
if (!panes.Output.fileTraits(f.output).saves) return 0;
- const bytes = fs.read(p, f.path) catch |err| return switch (err) {
- error.FileNotFound => E.NOENT,
- else => E.IO,
+ const bytes = fs.read(p, f.path) catch |err| {
+ failed.* = err;
+ return switch (err) {
+ error.FileNotFound => E.NOENT,
+ else => E.IO,
+ };
};
// Throwing unsaved edits away for it is a reload the log hears of, as a
// clean one from the watch is.
@@ -1055,7 +1096,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename);
const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename;
try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n");
- try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\""));
+ try testing.expect(std.mem.endsWith(u8, no_theme, "; Themes lists all \"Theme no-such-theme\""));
try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename);
try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename);
// A bare required word fails the check, so the line before never runs.
@@ -1065,7 +1106,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
// its line; the lines before it have taken effect, as in acme.
const failed = wr(p, root_ctl, "Verbose on\nMount x\nVerbose off");
try testing.expectEqual(E.IO, failed.errno());
- try testing.expectEqualStrings("Mount name dial: missing argument \"Mount x\"", failed.reply.ename);
+ try testing.expectEqualStrings("Mount name dial: missing argument", failed.reply.ename);
try testing.expect(p.settings.verbose);
try testing.expect(!p.fs.no_prompt);
try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off").reply.status);
@@ -1413,16 +1454,17 @@ test "get reloads the pane from disk and reports a missing file without losing d
try testing.expectEqualStrings("from disk\n", pane.file.?.content);
try testing.expect(!pane_files.dirtyOf(pane));
try testing.expect(pane.file.?.history.undo_len > 0);
- // What it had (renamed, so dirty) was thrown away for it: logged.
+ // Renamed but never edited, it was clean: nothing thrown away, nothing
+ // logged as reloaded over edits.
var reloaded: [32]u8 = undefined;
- try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial})));
+ try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial})));
_ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory}));
_ = wr(p, Node.of(serial, .body), "unsaved\n");
// Unsaved text is asked about once, as acme's get asks winclean.
const asked = wr(p, ctl_node, "get\n");
try testing.expectEqual(E.IO, asked.errno());
- try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard) \"get\""));
+ try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard)"));
try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\n").errno());
try testing.expect(p.paneBySerial(serial) != null);
try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content);
@@ -1436,7 +1478,7 @@ test "exec Save writes the named file through the host" {
const serial = serialOf(p);
const pane = p.panes[0].?;
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status);
- try testing.expect(pane_files.dirtyOf(pane));
+ try testing.expect(!pane_files.dirtyOf(pane)); // a name alone is no edit; Save writes all the same
const saved = wr(p, Node.of(serial, .exec), "Save\n");
try testing.expectEqual(Status.ok, saved.reply.status);
try testing.expect(saved.saved);
@@ -1504,6 +1546,11 @@ test "a builtin's Verbose announcement is logged only when it succeeds, its fail
const serial = serialOf(p);
var want: [64]u8 = undefined;
const said_exit = try std.fmt.bufPrint(&want, "msg {d} Exit\n", .{serial});
+ // A Save a ctl write gives no name to, on a scratch: refused, no Save.
+ const scratch = try th.newPane(p);
+ var said_save_buf: [64]u8 = undefined;
+ try testing.expectEqual(E.INVAL, wr(p, Node.of(scratch, .ctl), "Save\n").errno());
+ try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&said_save_buf, "msg {d} Save\n", .{scratch})));
// Refused as it runs, through a click: the write fails, and no Exit.
const f = &p.panes[0].?.file.?;
f.saved_revision = f.revision -% 1;
@@ -1615,7 +1662,7 @@ test "a Restore of a file that is no dump fails the write before any warning, an
p.fs.late_failure_len = 0;
p.dumpFailed("/nowhere/pardes.dump.zon", error.FileNotFound);
- try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: file not found", p.fs.late_failure[0..p.fs.late_failure_len]);
+ try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: no such directory", p.fs.late_failure[0..p.fs.late_failure_len]);
}
test "a setting this frontend cannot show says GUI-only, and DumpDir reads back the directory in effect" {
@@ -1623,8 +1670,8 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back
defer p.deinit();
const root_ctl = @intFromEnum(tree.TopFile.ctl);
if (!builtins.capabilities.lift) {
- try testing.expectEqualStrings("Lift is GUI-only \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename);
- try testing.expectEqualStrings("GripWidth is GUI-only \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename);
+ try testing.expectEqualStrings("Lift is GUI-only, invalid here \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename);
+ try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename);
}
try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename);
var buf: [900]u8 = undefined;
@@ -1665,7 +1712,7 @@ test "a builtin that fails a ctl write logs only its err, so the same failure ag
const log = @intFromEnum(tree.TopFile.log);
const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
- try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word \"Kill zzz\" (x3)\n"));
+ try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word (x3)\n"));
try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null);
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
@@ -1677,7 +1724,10 @@ test "an exec line over the command limit fails the write and says the limit; a
const refused = wr(p, root_exec, long);
try testing.expectEqual(E.INVAL, refused.errno());
try testing.expectEqualStrings(e_too_long, refused.reply.ename);
- try testing.expect(th.logHas(p, "a command line is at most 1024 bytes"));
+ var rec: [96]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err {d} exec: invalid command line: a command line is at most 1024 bytes", .{serialOf(p)})));
+ try testing.expectEqualStrings(e_control, wr(p, root_exec, "echo a\x01b\n").reply.ename);
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err {d} exec: invalid command line: it holds", .{serialOf(p)})));
try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg " ++ "z" ** 1100 ++ "\n").reply.status);
}
@@ -1721,8 +1771,13 @@ test "Mount dials its peer and fails, mounting nothing, when none answers; a loo
const dead = "/tmp/pardes-test-no-such-peer.sock";
const refused = wr(p, root_ctl, "Mount dead " ++ dead ++ "\n");
try testing.expectEqual(E.IO, refused.errno());
- try testing.expectEqualStrings("Mount dead " ++ dead ++ ": dial failed: no answer \"Mount dead " ++ dead ++ "\"", refused.reply.ename);
+ try testing.expectEqualStrings("Mount dead " ++ dead ++ ": dial failed: no answer", refused.reply.ename);
try testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len);
+ // A pane of a peer gone since: get says so too.
+ try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "away"), .dial = try p.gpa.dupe(u8, dead) });
+ _ = wr(p, Node.of(serialOf(p), .name), "/n/away/f.txt\n");
+ const got = wr(p, Node.of(serialOf(p), .ctl), "get\n");
+ try testing.expectEqualStrings("get: /n/away/f.txt: dial failed: no answer", got.reply.ename);
// Mounted when it answered, gone since: a look through it says so.
try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "gone"), .dial = try p.gpa.dupe(u8, dead) });
const looked = wr(p, @intFromEnum(tree.TopFile.look), "/n/gone/f.txt\n");
@@ -1741,6 +1796,373 @@ test "a root exec that fails logs its err against the pane it ran at" {
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: ", .{serialOf(p)})));
}
+test "Tty in a directory that is not there fails and makes no pane; a shell that never started answers a run" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n");
+ // A host that starts shells (the core alone starts none, and asks
+ // nothing of a directory).
+ const Starting = struct {
+ fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {}
+ };
+ p.host = .{ .vtable = &.{ .spawn = Starting.spawn } };
+ defer p.host = .{};
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const refused = wr(p, Node.of(serial, .ctl), "Tty\n");
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "Tty: /tmp/pardes-no-such-dir-zz: no such directory");
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // A terminal whose shell the host could not start.
+ _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status);
+ const term = p.active;
+ p.shellFailed(@intCast(term), error.FileNotFound);
+ const run_node = Node.of(p.panes[term].?.serial, .pty_run);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = run_node, .omode = 2 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = run_node, .handle = h, .data = "true\n" });
+ try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 3, .op = .read, .node = run_node, .handle = h, .size = 64 }).bytes);
+}
+
+test "a builtin that needs its argument, written bare to an exec, is EINVAL as through ctl" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for ([_]u64{ root_exec, Node.of(serialOf(p), .exec), @intFromEnum(tree.TopFile.tagexec) }) |node| {
+ const refused = wr(p, node, "Mount\n");
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectEqualStrings("wrong #args in control message \"Mount\"", refused.reply.ename);
+ }
+}
+
+test "Grep with no hit says grep: no match and opens nothing, a missing pane directory passed over" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "alpha\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "one\nbravo here\nthree bravo\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/a.txt\n", .{dir}));
+ // A hit opens its +Search, a row a line: exec reads it back. Find and
+ // Grep share it, a directory's one +Search, each run replacing it.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Grep bravo\n").reply.status);
+ const grepped = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ try testing.expectEqual(@as(usize, 2), std.mem.count(u8, grepped.file.?.content, "other.txt:"));
+ try testing.expect(std.mem.indexOf(u8, grepped.file.?.content, "other.txt:2") != null);
+ try testing.expect(std.mem.indexOf(u8, grepped.file.?.content, "other.txt:3") != null);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Find other\n").reply.status);
+ const found = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ try testing.expect(std.mem.indexOf(u8, found.file.?.content, "other.txt") != null);
+ try testing.expectEqual(grepped.serial, found.serial);
+ try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") == null);
+ // A second pane in a directory that is not there.
+ const other = try th.newPane(p);
+ _ = wr(p, Node.of(other, .name), "/tmp/pardes-no-such-dir-zz/b.txt\n");
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n");
+ try testing.expectEqual(E.NOENT, missed.errno());
+ try testing.expectEqualStrings("grep: no match, not found", missed.reply.ename);
+ const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n");
+ try testing.expectEqual(E.NOENT, unfound.errno());
+ try testing.expectEqualStrings("find: no match, not found", unfound.reply.ename);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+}
+
+test "Unmount of a name not mounted says no such mount, which a mount reads as ENOENT" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const refused = wr(p, @intFromEnum(tree.TopFile.ctl), "Unmount nothere\n");
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "Unmount: no such mount nothere");
+}
+
+test "a terminal told the directory it already goes by logs no rename" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ _ = wr(p, Node.of(serialOf(p), .name), "/tmp/f.txt\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .ctl), "Tty\n").reply.status);
+ p.update(.tick); // announced
+ const term = p.active;
+ // The host says where its shell started: where it was already named.
+ p.setCwd(term, "/tmp");
+ var rec: [32]u8 = undefined;
+ try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} ", .{p.panes[term].?.serial})));
+ // This process's directory, asked before the shell has said anything
+ // (so before its chdir): not taken.
+ const session = p.sessionDir();
+ p.setCwd(term, session);
+ try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} ", .{p.panes[term].?.serial})));
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "$ " } });
+ // Somewhere else is a rename.
+ p.setCwd(term, "/usr");
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} /usr", .{p.panes[term].?.serial})));
+}
+
+test "a terminal with text typed at its prompt is never typed into: a look at its directory opens another" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const term = p.paneBySerial(serialOf(p)).?;
+ p.setCwd(term, "/tmp");
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07TYPED" } });
+ while (p.nextEffect()) |_| {}
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.look), "/tmp\n").reply.status);
+ var typed_into = false;
+ while (p.nextEffect()) |effect| if (effect == .write and effect.write.pane == term) {
+ typed_into = true;
+ };
+ try testing.expect(!typed_into);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count + 1, after);
+ // An exec at it runs in a pane of its own, not typed after TYPED.
+ _ = wr(p, Node.of(serialOf(p), .exec), "echo hi\n");
+ while (p.nextEffect()) |effect| if (effect == .write and effect.write.pane == term) {
+ typed_into = true;
+ };
+ try testing.expect(!typed_into);
+}
+
+test "a command line from a missing directory fails ENOENT and makes no pane; a command whose shell never started ends 127" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const Starting = struct {
+ fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {}
+ };
+ p.host = .{ .vtable = &.{ .spawn = Starting.spawn } };
+ defer p.host = .{};
+ const serial = serialOf(p);
+ _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n");
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const refused = wr(p, Node.of(serial, .exec), "echo hi\n");
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory");
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // A command pane whose shell the host could not start.
+ _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "echo hi\n").reply.status);
+ const cmd = p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, Node.of(serial, .exec), 0, 64).bytes, "\n"), 10)).?;
+ p.shellFailed(@intCast(cmd), error.FileNotFound);
+ const pane = p.panes[cmd].?;
+ try testing.expect(pane.command_done);
+ try testing.expectEqual(@as(?u8, 127), pane.command_status);
+ var rec: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "exit {d} 127\n", .{pane.serial})));
+ try testing.expect(th.logHas(p, "shell: directory missing"));
+}
+
+test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot start fails the write" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const Starting = struct {
+ fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {}
+ };
+ p.host = .{ .vtable = &.{ .spawn = Starting.spawn } };
+ defer p.host = .{};
+ const serial = serialOf(p);
+ p.setCwd(p.paneBySerial(serial).?, "/tmp/pardes-no-such-dir-zz");
+ const refused = wr(p, Node.of(serial, .pty_ctl), "exec\n");
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory");
+ // The host's own failure is the waiting write's (late_failure).
+ p.fs.late_failure_len = 0;
+ p.shellFailed(@intCast(p.paneBySerial(serial).?), error.AccessDenied);
+ try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]);
+}
+
+test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log, .omode = 2 }).reply.handle;
+ const Case = struct { node: u64, data: []const u8, words: []const u8, handle: u32 = 0 };
+ for ([_]Case{
+ .{ .node = root_exec, .data = "echo a\x7fb\n", .words = "control character" },
+ .{ .node = root_look, .data = "a\x7fb\n", .words = "control character" },
+ .{ .node = Node.of(serial, .name), .data = "\n", .words = "an empty name" },
+ .{ .node = log, .data = "bogus\n", .words = "takes `follow`", .handle = h },
+ }) |c| {
+ const refused = call(p, .{ .tag = 2, .op = .write, .node = c.node, .handle = c.handle, .data = c.data });
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c.words) != null);
+ }
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ try testing.expect(!th.logHas(p, "Invalid argument"));
+ try testing.expect(th.logHas(p, "an empty name"));
+}
+
+test "/index shows a name as the log does: a newline in it is \\n, controls spaces, bytes not UTF-8 \\xNN" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const term = p.paneBySerial(serialOf(p)).?;
+ p.setCwd(term, "/tmp/two\nlines\x7f\xff");
+ const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes;
+ try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines \\xff ") != null);
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, index, "\n"));
+ try testing.expectEqual(@as(u64, index.len), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(tree.TopFile.index) }).reply.attr.size);
+ // Its tag never carries the raw newline either.
+ const tag = rd(p, Node.of(serialOf(p), .tag), 0, 4096).bytes;
+ try testing.expect(std.mem.indexOf(u8, tag, "/tmp/two\\nlines") != null);
+ // A backslash of the name's own is doubled: `\n` written in a name
+ // reads back unlike a newline.
+ p.setCwd(term, "/tmp/back\\nslash");
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, "/tmp/back\\\\nslash") != null);
+}
+
+test "a served-tree pane spoils no Grep, and Look . from a gone directory is ENOENT, not /virtual/" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "hit.txt", .data = "needle here\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/other.txt\n", .{dir}));
+ // A pane of the served tree.
+ const virtual = try th.newPane(p);
+ _ = wr(p, Node.of(virtual, .name), "/virtual/index\n");
+ // One named the served tree's root, whose directory is `/`.
+ const top = try th.newPane(p);
+ _ = wr(p, Node.of(top, .name), "/virtual\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Grep needle\n").reply.status);
+ // `.` from a pane whose directory is gone.
+ const gone = try th.newPane(p);
+ _ = wr(p, Node.of(gone, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n");
+ const looked = wr(p, Node.of(gone, .look), ".\n");
+ try testing.expectEqual(E.NOENT, looked.errno());
+ try testing.expectStringStartsWith(looked.reply.ename, "look: /tmp/pardes-no-such-dir-zz: no such directory");
+}
+
+test "a tag write refuses NUL and the other control characters" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for ([_]u64{ @intFromEnum(tree.TopFile.tag), tree.Node.ofCol(pardes.layout.columnSerial(p, 0), .tag), Node.of(serialOf(p), .tag) }) |node| {
+ const refused = wr(p, node, " a\x00b");
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "invalid tag text");
+ try testing.expectEqual(E.INVAL, wr(p, node, " \x7f").errno());
+ }
+}
+
+test "a malformed address is bad address syntax, on a terminal as on a text" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const t = try withFile(testing.allocator, "one\n");
+ defer t.deinit();
+ for ([_]*pardes.Pardes{ p, t }) |q| {
+ for ([_][]const u8{ "1x", "/(/", ",,x" }) |expr| {
+ const refused = wr(q, Node.of(serialOf(q), .addr), expr);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expect(refused.reply.ename.len > 0);
+ }
+ try testing.expectEqualStrings("bad address syntax", wr(q, Node.of(serialOf(q), .addr), "1x").reply.ename);
+ }
+}
+
+test "a bad event record says what is wrong with it" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const event = Node.of(serialOf(p), .event);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = event, .omode = 2 }).reply.handle;
+ for ([_][2][]const u8{
+ .{ "MI0 1 0 1 x\n", "only a look or exec" },
+ .{ "MX3 1 0 0 \n", "ends before it starts" },
+ .{ "MX9 9 0 0 \n", "range past end" },
+ .{ "garbage\n", "a record is" },
+ }) |c| {
+ const refused = call(p, .{ .tag = 2, .op = .write, .node = event, .handle = h, .data = c[0] });
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c[1]) != null);
+ }
+ _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h });
+}
+
+test "a column takes panes until their documented minimums fill it, not until none can be halved" {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 50 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const new = @intFromEnum(tree.TopFile.new);
+ while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync();
+ p.sync();
+ const avail = p.screen_h - (p.topBarHeight() + p.columnBarHeight());
+ for (0..p.ncol) |c| {
+ var need: u32 = 0;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| need += layout.minRows(p, k);
+ // Refused only when one more pane's tag and two rows would not fit.
+ if (p.col_n[c] > 1) try testing.expect(avail - need < 3);
+ // Every pane keeps its minimum.
+ for (p.col_panes[c][0..p.col_n[c]]) |k| try testing.expect(p.rects[k].h >= layout.minRows(p, k));
+ }
+}
+
+test "a closed file is found again: Recent and /recent list it closed, a look at its row reopens it at its dot, and a jump back does too" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\ntwo\nthree\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ const a_path = try std.fmt.bufPrint(&line, "{s}/a.txt", .{dir});
+ var a_buf: [4200]u8 = undefined;
+ const a = a_buf[0..a_path.len];
+ @memcpy(a, a_path);
+ // Opened, its dot on line 3, then closed.
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}:3\n", .{a})).reply.status);
+ p.sync();
+ const opened = p.active;
+ try testing.expectEqualStrings(a, p.panes[opened].?.file.?.path);
+ var want: [4300]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "open {s}\n", .{a})) != null);
+ // Another file, so the jumplist has somewhere to come back from.
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir})).reply.status);
+ p.sync();
+ try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[opened].?.serial, .ctl), "Del\n").reply.status);
+ p.sync();
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "closed {s}\n", .{a})) != null);
+ // Recent lists it, closed, at its dot; a look at the row opens it there.
+ try testing.expectEqual(Status.ok, wr(p, root_exec, "Recent\n").reply.status);
+ const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ const at = std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}:3:", .{a})).?;
+ const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?];
+ try testing.expect(std.mem.endsWith(u8, row, " closed"));
+ try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " closed".len]})).reply.status);
+ p.sync();
+ const back = p.panes[p.active].?;
+ try testing.expectEqualStrings(a, back.file.?.path);
+ try testing.expectEqual(@as(i32, 2), back.body.cur_row);
+ // Closed again; the jumplist keeps its entry, closed, and a jump back
+ // to it opens it again.
+ _ = wr(p, Node.of(back.serial, .ctl), "Del\n");
+ p.sync();
+ var closed_entry = false;
+ for (p.jumps[0..p.njumps]) |j| closed_entry = closed_entry or j.serial == 0;
+ try testing.expect(closed_entry);
+ var tries: usize = 0;
+ while (tries < p.njumps) : (tries += 1) {
+ p.jumpBy(-1);
+ p.sync();
+ if (p.panes[p.active].?.file) |f| if (std.mem.eql(u8, f.path, a)) break;
+ }
+ try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path);
+}
+
test "size is monotonic: growing is never refused, and a size once taken is taken again" {
const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 });
defer p.deinit();
@@ -2098,4 +2520,10 @@ test "@p<terminal>:<address> takes any address over the terminal's lines, select
try testing.expectEqual(@as(i32, 1), pane.body.cur_col);
_ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial}));
try testing.expect(th.logHas(p, "look: no match for regexp"));
+ // A line past its last is a miss, as in a file.
+ const row_before = pane.body.cur_row;
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:999\n", .{serial}));
+ try testing.expect(th.logHas(p, "has no line 999"));
+ try testing.expectEqual(row_before, pane.body.cur_row);
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
}