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.zig847
1 files changed, 815 insertions, 32 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 2fdc47cc..4f52d661 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -51,16 +51,19 @@ fn noteResult(p: *Pardes, serial: u32) void {
/// Runs one look or exec from the pane `id`, then records the panes it
/// created; failing that, a look records the pane it focused and an exec
/// the pane it acted on (even one it closed).
-pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
+pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) void {
const context = p.panes[id].?.serial;
// A pane an event reader holds hears its clicks instead of acting on
// them (editors/acme/look.c:53 and exec.c:173 send them to winevent),
// and a line written to a look or exec is a click: on that pane, or for
// the root's, on the pane with the keyboard. The reader writes the
// record back to have it done.
- if (p.panes[id].?.fs.readers > 0) {
- const named = std.meta.stringToEnum(Builtin, exec_line.commandText(text)) != null;
- var flag: u32 = pardes.ctlfs.events.flag_expansion;
+ // A click in a column's or the workspace's tag is not the pane's.
+ if (!in_tag and p.panes[id].?.fs.readers > 0) {
+ const named = exec_line.namesBuiltin(text);
+ // No flag 2: a written line was never expanded from a click, and no
+ // expansion record follows (editors/acme/look.c:42-43, exec.c:154-157).
+ var flag: u32 = 0;
if (named) flag |= pardes.ctlfs.events.flag_builtin;
if (!exec and !named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= pardes.ctlfs.events.flag_filename;
_ = pardes.ctlfs.events.noteAction(p, id, if (exec) .body_exec else .body_look, 0, 0, flag, text);
@@ -79,9 +82,8 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const after = Serials.snapshot(p);
var created = false;
for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
- // A look's hits buffer is how it found the text, not where: it
- // answers the pane the hit is selected in.
- if (!exec) if (p.paneBySerial(serial)) |made| if (p.panes[made].?.file) |f| if (f.output) |o| if (o.from == .search) continue;
+ // A look that made a +Search pane (LookWord list, a terminal)
+ // answers it: that is where the places it found are.
noteResult(p, serial);
created = true;
};
@@ -112,34 +114,77 @@ const Messages = struct {
const edit = "Edit";
if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t'))
return first;
- const sam = @import("../sam_edit.zig");
- while (m.at < m.data.len and sam.needsMore(m.p.scratch.allocator(), std.mem.trim(u8, m.data[start..end], " \t\r")[edit.len..])) {
- end = std.mem.indexOfScalarPos(u8, m.data, m.at, '\n') orelse m.data.len;
- m.at = end + 1;
- }
+ end = editEnd(m.p, m.data, start, end, true).?;
+ m.at = end + 1;
return std.mem.trim(u8, m.data[start..end], " \t\r");
}
};
+/// Where the Edit block whose first line is `data[start..first_end]` ends:
+/// the end of its last line, taking the lines after it while its `{`
+/// group or a/c/i text block is open. Out of lines, the end of `data` when
+/// `to_end` (a write run whole), else null (wait for more). Only a line
+/// that could end what is open is parsed again with all before it: within
+/// a text block, just a `.` line. The arena copy of each parse is freed.
+fn editEnd(p: *Pardes, data: []const u8, start: usize, first_end: usize, to_end: bool) ?usize {
+ const sam = @import("../sam_edit.zig");
+ const edit = "Edit";
+ const arena = p.scratch.allocator();
+ var end = first_end;
+ var wait = sam.waitsFor(arena, std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]);
+ while (wait != .none) {
+ if (end >= data.len) return if (to_end) data.len else null;
+ const next = std.mem.indexOfScalarPos(u8, data, end + 1, '\n') orelse
+ (if (to_end) data.len else return null);
+ const line = std.mem.trim(u8, data[end + 1 .. next], " \t\r");
+ end = next;
+ if (wait == .text and !std.mem.eql(u8, line, ".")) continue;
+ wait = sam.waitsFor(arena, std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]);
+ }
+ return end;
+}
+
+/// How much of `data` is whole messages, as `Messages` reads them: lines
+/// ended by a newline, an Edit block only once it closes. The rest waits
+/// for more (tree.Open.pending).
+pub fn completeEnd(p: *Pardes, data: []const u8) usize {
+ const edit = "Edit";
+ var whole: usize = 0;
+ var at: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, data, at, '\n')) |nl| {
+ const start = at;
+ var end = nl;
+ const first = std.mem.trim(u8, data[start..end], " \t\r");
+ if (std.mem.startsWith(u8, first, edit) and (first.len == edit.len or first[edit.len] == ' ' or first[edit.len] == '\t'))
+ end = editEnd(p, data, start, end, false) orelse return whole;
+ at = end + 1;
+ whole = at;
+ }
+ return whole;
+}
+
// ---- look and exec ----
/// One command per line, checked whole before any line runs: a line is the
/// clicked text itself, so it may not be empty or carry control characters.
/// 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) Reply {
+pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Reply {
+ p.fs.ran_at = 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 Reply.fail(req.tag, E.INVAL);
+ for (text) |c| if (c < ' ' and c != '\t' and c != '\n') 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;
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
+ p.fs.ran_at = p.panes[id].?.serial;
p.fs.no_pane_slot = false;
- run(p, id, exec, text);
+ if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal;
// A pane it would have opened had no slot: the write fails, and
// a look reads back nothing.
if (p.fs.no_pane_slot) {
@@ -151,6 +196,38 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
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});
+
+/// 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
+/// row after the write succeeded. A builtin's line (Msg, an Edit block) may
+/// be longer.
+pub fn tooLong(req: Req, line: []const u8) ?Reply {
+ if (line.len <= pardes.exec.command_max) return null;
+ const cmd = exec_line.commandText(line);
+ const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len];
+ if (std.meta.stringToEnum(Builtin, word) != null) return null;
+ return tree.failText(req.tag, E.INVAL, e_too_long);
+}
+
+pub const e_control = "a command line holds no control character but 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 {
+ p.fs.capturing = true;
+ p.fs.failure_len = 0;
+ defer p.fs.capturing = false;
+ run(p, id, exec, text, in_tag);
+ if (p.fs.failure_len == 0) return null;
+ // A failed click answers nothing: look reads back empty.
+ 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);
+}
+
pub fn resultsLen(p: *Pardes) u64 {
var n: u64 = 0;
for (p.fs.results[0..p.fs.results_len]) |serial| {
@@ -160,11 +237,28 @@ pub fn resultsLen(p: *Pardes) u64 {
return n;
}
+/// The panes the open's last write touched (tree.Open.results); with no
+/// open, the session's last.
pub fn readResults(p: *Pardes, req: Req) Reply {
const out = p.fs.stage(p.gpa);
- for (p.fs.results[0..p.fs.results_len]) |serial|
+ const o = tree.openOf(p, req);
+ const kept = if (o) |open| open.results.list[0..open.results.len] else p.fs.results[0..p.fs.results_len];
+ // A look answers panes to go to: never one closed since (an exec may
+ // answer the pane it closed, which is what it acted on).
+ const looking = if (tree.Node.target(req.node)) |t| switch (t) {
+ .top => |f| f == .look,
+ .pane => |pt| pt.file == .look,
+ .col => false,
+ } else false;
+ for (kept) |serial| if (!looking or p.paneBySerial(serial) != null)
out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM);
- return tree.stagedReply(p, req);
+ const open = o orelse return tree.stagedReply(p, req);
+ if (!open.results.wrote) return tree.stagedReply(p, req);
+ var at = req;
+ at.off = open.results.read;
+ const reply = tree.stagedReply(p, at);
+ open.results.read += reply.payload.staged;
+ return reply;
}
// ---- /status ----
@@ -366,7 +460,7 @@ pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer)
.theme => try w.print("{s} {s}\n", .{ word, if (s == &p.settings) p.theme().name else pardes.themes[s.theme].name }),
.shell => {
const chosen = s.shell.requested.get();
- try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.default_shell });
+ try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.defaultShell() });
},
.font => {
const requested = s.font.requested_name.get();
@@ -424,6 +518,30 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
var it = Messages.init(p, req.data);
while (it.next()) |line| {
if (line.len == 0) continue;
+ if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) {
+ const size = sizeOf(line) orelse return refuse(p, req, e_size, line);
+ // A frontend's window is the size; only a session none is
+ // attached to (--detach, unattended) takes one written.
+ if (!p.unattended) return refuse(p, req, "size: a frontend is attached and owns the size", line);
+ if (apply) {
+ const was: [2]u16 = .{ p.screen_w, p.screen_h };
+ const before = p.rects;
+ p.update(.{ .resize = .{ .cols = size[0], .rows = size[1] } });
+ p.sync();
+ // Every pane keeps its tag and two rows, as placement
+ // leaves them (layout.minRows, the one minimum): a size
+ // too small for the panes' minima is refused, and one
+ // they fit gives back rows the resize took under them.
+ if (!pardes.layout.minimaFit(p)) {
+ p.update(.{ .resize = .{ .cols = was[0], .rows = was[1] } });
+ p.sync();
+ return refuse(p, req, "size: too small for the panes, each its tag and 2 rows", line);
+ }
+ pardes.layout.restoreMinima(p, &before);
+ p.sync();
+ }
+ continue;
+ }
if (!apply) {
if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
continue;
@@ -437,17 +555,88 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+const size_min = [2]u16{ 20, 6 };
+const size_max = [2]u16{ 4096, 4096 };
+// "invalid": the word 9ns reads as EINVAL (enameToErrno), as winsize's.
+const e_size = std.fmt.comptimePrint("invalid size: takes <cols> <rows>, at least {d}x{d}, at most {d}x{d}", .{ size_min[0], size_min[1], size_max[0], size_max[1] });
+
+/// `size <cols> <rows>`, within size_min and size_max.
+fn sizeOf(line: []const u8) ?[2]u16 {
+ var it = std.mem.tokenizeAny(u8, line["size".len..], " \t");
+ const cols = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
+ const rows = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
+ if (it.next() != null or cols < size_min[0] or rows < size_min[1] or cols > size_max[0] or rows > size_max[1]) return null;
+ return .{ cols, rows };
+}
+
// ---- /commands ----
/// Every builtin the registry holds, one a line: its word, `arg` when it
/// takes one, and which ctl takes it, `root` or `pane` -- the registry
/// itself, so a new builtin is listed the moment it exists.
+/// A builtin's doc comment, its first sentence, from the source itself: a
+/// line of /commands says what each word does without a second copy of it.
+fn summaryOf(word: []const u8, buf: []u8) []const u8 {
+ const source = @embedFile("../builtins.zig");
+ var needle_buf: [64]u8 = undefined;
+ const needle = std.fmt.bufPrint(&needle_buf, "\npub const {s} = struct", .{word}) catch return "";
+ const at = std.mem.indexOf(u8, source, needle) orelse return "";
+ // Back over the `///` lines just above it.
+ var start = at + 1;
+ while (start > 0) {
+ const prev_end = start - 1;
+ const prev_start = if (std.mem.lastIndexOfScalar(u8, source[0..prev_end], '\n')) |nl| nl + 1 else 0;
+ if (!std.mem.startsWith(u8, std.mem.trimStart(u8, source[prev_start..prev_end], " "), "///")) break;
+ start = prev_start;
+ }
+ var w = std.Io.Writer.fixed(buf);
+ var lines = std.mem.splitScalar(u8, source[start .. at + 1], '\n');
+ while (lines.next()) |line| {
+ const text = std.mem.trim(u8, std.mem.trimStart(u8, std.mem.trimStart(u8, line, " "), "/"), " ");
+ if (text.len == 0) continue;
+ if (w.buffered().len > 0) w.writeByte(' ') catch break;
+ w.writeAll(text) catch break;
+ }
+ const all = stripCitations(w.buffered());
+ // The first sentence, whole: to a `.` that ends one.
+ var i: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, all, i, '.')) |dot| : (i = dot + 1) {
+ if (dot + 1 == all.len or all[dot + 1] == ' ') return all[0 .. dot + 1];
+ }
+ return all;
+}
+
+/// Drops a parenthesis that cites source (`(editors/acme/exec.c:892)`,
+/// `(exec.c, \`kill\`; acme.c's WKill)`): a reader of /commands wants what
+/// the word does, not where acme has it. In place; answers the rest.
+fn stripCitations(text: []u8) []u8 {
+ var out: usize = 0;
+ var i: usize = 0;
+ while (i < text.len) {
+ if (text[i] == '(') if (std.mem.indexOfScalarPos(u8, text, i, ')')) |close| {
+ const inside = text[i + 1 .. close];
+ if (std.mem.indexOf(u8, inside, ".c") != null or std.mem.indexOf(u8, inside, ".zig") != null) {
+ // With the space before it, and a colon after it kept.
+ if (out > 0 and text[out - 1] == ' ') out -= 1;
+ i = close + 1;
+ continue;
+ }
+ };
+ text[out] = text[i];
+ out += 1;
+ i += 1;
+ }
+ return text[0..out];
+}
+
fn writeCommands(w: *std.Io.Writer) !void {
for (std.enums.values(Builtin)) |b| {
+ // Edit is a pane's word the root's ctl takes too, at the active pane.
+ const scope: []const u8 = if (b == .Edit) "both" else if (builtins.registry.scope(b) == .session) "root" else "pane";
try w.print("{s}{s} {s}", .{
@tagName(b),
if (builtins.registry.takesArg(b)) " arg" else "",
- if (builtins.registry.scope(b) == .session) "root" else "pane",
+ scope,
});
// A setting that chooses among words: the words, comma-joined. Bare,
// it steps to the next of them (a two-valued one flips).
@@ -457,6 +646,14 @@ fn writeCommands(w: *std.Io.Writer) !void {
var first = true;
while (parts.next()) |v| : (first = false) try w.print("{s}{s}", .{ if (first) "" else ",", v });
};
+ // Then ` -- ` and what it does: a setting's doc, else its source's.
+ var summary_buf: [2048]u8 = undefined;
+ const summary = if (config.Runtime.find(@tagName(b))) |setting| setting.doc else summaryOf(@tagName(b), &summary_buf);
+ if (summary.len > 0) {
+ // The sentence whole unless it is long, then cut between words.
+ const kept = @import("../Messages.zig").clip(summary, 240);
+ try w.print(" -- {s}{s}", .{ kept, if (kept.len < summary.len) "…" else "" });
+ }
try w.writeByte('\n');
}
}
@@ -550,9 +747,21 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
if (std.mem.eql(u8, line, "get")) {
if (!apply) continue;
- if (getRefused(p, pane)) |said| return tree.failText(req.tag, E.IO, said);
+ // Quoting the line, as a builtin's refusal (Save's) does.
+ if (getRefused(p, pane)) |said_in_ename| {
+ // refuse writes ename, where the words are: copy them out.
+ 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 };
+ }
const errno = get(p, pane);
- if (errno != 0) return Reply.fail(req.tag, errno);
+ // Naming the path, in words 9ns reads as the errno.
+ 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");
+ }
} else if (std.mem.eql(u8, line, "lock")) {
if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
if (other) return tree.failText(req.tag, E.BUSY, e_locked);
@@ -562,6 +771,24 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (!held) return tree.failText(req.tag, E.INVAL, e_not_locked);
held = false;
if (apply) pane.fs.lock = null;
+ } else if (std.mem.eql(u8, line, "answer")) {
+ // With a question up, what it offers.
+ var offered: [64]u8 = undefined;
+ if (p.questionChoices(p.paneBySerial(serial).?, &offered)) |choices| {
+ var why: [96]u8 = undefined;
+ return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "answer takes {s} or -", .{choices}) catch std.fmt.bufPrint(&why, "answer takes a choice or -", .{}) catch "answer takes a choice or -");
+ }
+ return tree.failText(req.tag, E.INVAL, "answer takes a choice or -");
+ } else if (std.mem.startsWith(u8, line, "answer ") and p.panes[p.paneBySerial(serial).?].?.prompt == .save) {
+ // Save's path, asked over 9P (`ask <serial> save path`).
+ if (!apply) continue;
+ const id = p.paneBySerial(serial).?;
+ const asked = p.panes[id].?;
+ const path = std.mem.trim(u8, line["answer ".len..], " ");
+ var rec: [300]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} {s}", .{ serial, path[0..@min(path.len, 256)] }) catch "");
+ if (!std.mem.eql(u8, path, "-")) exec_line.saveTo(p, id, path);
+ asked.closePrompt();
} else if (std.mem.startsWith(u8, line, "answer ")) {
// The pane's question (its `ask` in the log), answered.
const choice = std.mem.trim(u8, line["answer ".len..], " ");
@@ -585,7 +812,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
-const e_not_locked = "window not locked by this open";
+const e_not_locked = "pane not locked by this open";
/// Who holds it is another open of this ctl, whose client pardes cannot name.
const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock";
@@ -600,6 +827,9 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
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.
+ p.fs.unlogged = true;
+ defer p.fs.unlogged = false;
p.setMessage(p.paneBySerial(pane.serial).?, said);
return said;
}
@@ -611,10 +841,19 @@ fn get(p: *Pardes, pane: *Pane) u16 {
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.
+ const discarded = f.revision != f.saved_revision;
panes.File.pushUndo(p, pane);
panes.File.setContent(p, f, bytes);
f.saved_revision = f.revision;
f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.disk_newer = null;
+ f.disk_gone = false;
+ if (discarded) {
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} reloaded", .{pane.serial}) catch "");
+ }
return 0;
}
@@ -671,12 +910,13 @@ test "commands lists every builtin with its argument and the ctl that takes it"
var words = std.mem.tokenizeScalar(u8, line, ' ');
const b = std.meta.stringToEnum(Builtin, words.next().?).?;
if (builtins.registry.takesArg(b)) try testing.expectEqualStrings("arg", words.next().?);
- try testing.expectEqualStrings(if (builtins.registry.scope(b) == .session) "root" else "pane", words.next().?);
+ try testing.expectEqualStrings(if (b == .Edit) "both" else if (builtins.registry.scope(b) == .session) "root" else "pane", words.next().?);
if (config.Runtime.find(@tagName(b))) |s| if (config.Runtime.choices(s.action) != null) try testing.expect(words.next() != null);
- try testing.expect(words.next() == null);
+ // Then, where the source has one, ` -- ` and what it does.
+ if (words.next()) |dashes| try testing.expectEqualStrings("--", dashes);
}
try testing.expectEqual(std.enums.values(Builtin).len, lines);
- for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root on,off\n", "\nPlacement arg root acme,pardes\n", "\nUndo pane\n", "\nRedo pane\n" }) |row|
+ for ([_][]const u8{ "\nDel arg pane -- ", "\nNewcol root -- ", "\nSave arg pane", "\nVerbose arg root on,off", "\nPlacement arg root acme,pardes", "\nUndo pane -- ", "\nEdit arg both -- acme's Edit" }) |row|
try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null);
}
@@ -691,6 +931,15 @@ test "Undo and Redo on a pane's ctl step its body through its edits" {
try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes);
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes);
+ // Nothing left: said, and the write still succeeds, as acme's is silent.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
+ try testing.expect(th.logHas(p, "Redo: nothing to redo"));
+ _ = wr(p, Node.of(serial, .ctl), "Undo");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expect(th.logHas(p, "Undo: nothing to undo"));
+ // Said again word for word, it is that line counted, as err's are.
+ for (0..3) |_| _ = wr(p, Node.of(serial, .ctl), "Undo");
+ try testing.expect(th.logHas(p, "Undo: nothing to undo (x4)\n"));
}
test "focus reads the pane with the keyboard, and a serial written to it moves the keyboard there" {
@@ -932,8 +1181,8 @@ test "a message from a root ctl write is the session's in the log" {
const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len;
_ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Kill\n").reply.status);
- // The builtin's name, then what it said: both the session's.
- try testing.expectEqualStrings("msg - Kill\n", call(p, .{ .tag = 4, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes);
+ // What it said, the session's; its own words name it, so its Verbose
+ // announcement gives way to them.
try testing.expectEqualStrings("msg - Kill: nothing running\n", call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes);
}
@@ -980,6 +1229,37 @@ test "a look takes acme's :addr and file:addr, and a miss changes nothing" {
try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: ");
}
+test "the root look's file:/re/ goes on from the dot, and a miss leaves the dot as it was" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "foo x foo y\nfoo\n");
+ defer p.deinit();
+ const dot = Node.of(serialOf(p), .dot);
+ const root = @intFromEnum(tree.TopFile.look);
+ var line: [256]u8 = undefined;
+ const path = p.panes[0].?.file.?.path;
+ const again = try std.fmt.bufPrint(&line, "{s}:/foo/\n", .{path});
+ for ([_][]const u8{ " 0 3 ", " 6 9 ", " 12 15 ", " 0 3 " }) |want| {
+ try testing.expectEqual(Status.ok, wr(p, root, again).reply.status);
+ try testing.expectEqualStrings(want, rd(p, dot, 0, 64).bytes);
+ }
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "{s}:/zzqq/\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 64).bytes);
+}
+
+test "a look's miss repeats the path whole up to 256 bytes, then cuts it with an ellipsis" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const root = @intFromEnum(tree.TopFile.look);
+ const long = "some/deeply/nested/directory/that/goes/on/and/on/with/a_rather_long_file_name.txt";
+ var line: [512]u8 = undefined;
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "{s}\n", .{long}));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&line, "look: no match for \"{s}\"\n", .{long})));
+ const huge = "é" ** 150; // 300 bytes: cut at 256, between characters
+ _ = wr(p, root, huge ++ "\n");
+ try testing.expect(th.logHas(p, "look: no match for \"" ++ "é" ** 128 ++ "…\"\n"));
+}
+
test "exec runs a builtin at the pane and records the pane it acted on" {
const gpa = testing.allocator;
const p = try withFile(gpa, "Msg fs-ran\n");
@@ -1133,13 +1413,16 @@ 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.
+ var reloaded: [32]u8 = undefined;
+ 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)"));
+ try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard) \"get\""));
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);
@@ -1174,16 +1457,17 @@ test "a pane's own look and exec go to the event reader holding it, which writes
const pane = p.panes[0].?;
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") == null);
const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes;
- try testing.expectEqualStrings("FX0 0 2 10 Msg routed\n", record);
+ // Flag 1: its first word is a builtin; no 2, no expansion follows.
+ try testing.expectEqualStrings("FX0 0 1 10 Msg routed\n", record);
// Written back, it is done.
try testing.expectEqual(tree.Status.ok, wr(p, event, record).reply.status);
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") != null);
try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .look), "one.txt:3\n").reply.status);
- try testing.expectEqualStrings("FL0 0 6 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ try testing.expectEqualStrings("FL0 0 4 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
// The root's is a click at the pane with the keyboard, this one.
try testing.expectEqual(tree.Status.ok, wr(p, root_exec, "Msg rooted\n").reply.status);
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "rooted") == null);
- try testing.expectEqualStrings("FX0 0 2 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ try testing.expectEqualStrings("FX0 0 1 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
_ = call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
// With no reader, the root's and the pane's act at once.
try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg direct\n").reply.status);
@@ -1207,6 +1491,40 @@ test "look and exec read back what the last command touched, a ctl write's too"
// A ctl write that makes nothing names the pane it ran at.
try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Verbose off\n").reply.status);
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes);
+ // Closed, it is no pane to go to: a fresh read of look is empty.
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .ctl), "Del\n").reply.status);
+ try testing.expect(p.paneBySerial(made) == null);
+ try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes);
+}
+
+test "a builtin's Verbose announcement is logged only when it succeeds, its failure said now or by the host later" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ var want: [64]u8 = undefined;
+ const said_exit = try std.fmt.bufPrint(&want, "msg {d} Exit\n", .{serial});
+ // 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;
+ f.saved_hash = 0;
+ try testing.expect(wr(p, Node.of(serial, .exec), "Exit\n").errno() != 0);
+ try testing.expect(!th.logHas(p, said_exit));
+ // Refused at the keyboard (no write to fail), edited since: the same.
+ f.revision +%= 1;
+ _ = p.executeBuiltinLine(p.active, "Exit");
+ try testing.expect(!th.logHas(p, said_exit));
+ try testing.expect(!p.quit);
+ // A Save the host could not do, told a step later: the Save goes too.
+ var save_buf: [64]u8 = undefined;
+ const said_save = try std.fmt.bufPrint(&save_buf, "msg {d} Save\n", .{serial});
+ _ = wr(p, Node.of(serial, .exec), "Save\n");
+ p.saveFailed(@intCast(p.paneBySerial(serial).?), "/test.txt", error.AccessDenied);
+ try testing.expect(!th.logHas(p, said_save));
+ // One that does what it says keeps its announcement.
+ _ = wr(p, Node.of(serial, .exec), "Undo\n");
+ var undo_buf: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&undo_buf, "msg {d} Undo", .{serial})));
}
test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" {
@@ -1297,7 +1615,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: FileNotFound", p.fs.late_failure[0..p.fs.late_failure_len]);
+ try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: file not found", 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" {
@@ -1316,3 +1634,468 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back
try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, line) != null);
}
}
+
+test "Shell refuses a path that is no executable, and bare it goes back to the default" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const refused = wr(p, root_ctl, "Shell /nonexistent/zzsh\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: no shell \"/nonexistent/zzsh\"") != null);
+ try testing.expect(th.logHas(p, "no shell \"/nonexistent/zzsh\""));
+ try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno());
+ try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory"));
+ try testing.expectEqualStrings("", p.settings.shell.requested.get());
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell /bin/sh\n").reply.status);
+ try testing.expectEqualStrings("/bin/sh", p.settings.shell.requested.get());
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell\n").reply.status);
+ try testing.expectEqualStrings("", p.settings.shell.requested.get());
+ try testing.expect(th.logHas(p, "Shell: the default again, "));
+ var want: [300]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 8192).bytes, try std.fmt.bufPrint(&want, "Shell {s}\n", .{config.defaultShell()})) != null);
+}
+
+test "a builtin that fails a ctl write logs only its err, so the same failure again is counted" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ const ctl_node = @intFromEnum(tree.TopFile.ctl);
+ for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Kill zzz\n").reply.status);
+ 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.indexOf(u8, text, "msg - Kill") == null);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "an exec line over the command limit fails the write and says the limit; a builtin's may be longer" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const long = "echo " ++ "y" ** pardes.exec.command_max ++ "\n";
+ 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"));
+ try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg " ++ "z" ** 1100 ++ "\n").reply.status);
+}
+
+test "Edit's p and = never fail for want of room for +Errors: the text is logged instead" {
+ const p = try withFile(testing.allocator, "one\ntwo\n");
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 6 } });
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "Edit ,p\n").reply.status);
+ try testing.expect(th.logHas(p, " one\n") and th.logHas(p, " two\n"));
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(@as(usize, 1), count); // the +Errors that had no room is gone
+}
+
+test "size sets the screen of a session no frontend is attached to, and only of one" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120 40\n").errno()); // a frontend's
+ p.unattended = true;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "size 120 40\n").reply.status);
+ try testing.expectEqual(@as(u16, 120), p.screen_w);
+ try testing.expectEqual(@as(u16, 40), p.screen_h);
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120\n").errno());
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size\n").errno());
+ try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 5 2\n").reply.ename, "at least 20x6, at most 4096x4096") != null);
+ // Too small for the panes it has, each its tag and two rows: refused,
+ // the size as it was.
+ for (0..4) |_| p.newScratchBelow(p.active);
+ p.sync();
+ try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 80 8\n").reply.ename, "too small for the panes") != null);
+ try testing.expectEqual(@as(u16, 40), p.screen_h);
+}
+
+test "Mount dials its peer and fails, mounting nothing, when none answers; a look through a peer gone says dial failed" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ 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.expectEqual(@as(usize, 0), p.fs.mounts.items.len);
+ // 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");
+ try testing.expect(looked.errno() != 0);
+ try testing.expect(th.logHas(p, "look: /n/gone/f.txt: dial failed: no answer"));
+}
+
+test "a root exec that fails logs its err against the pane it ran at" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const f = &p.panes[0].?.file.?;
+ f.saved_revision = f.revision -% 1;
+ f.saved_hash = 0;
+ try testing.expect(wr(p, root_exec, "Exit\n").errno() != 0);
+ var want: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: ", .{serialOf(p)})));
+}
+
+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();
+ while (p.nextEffect()) |_| {}
+ p.unattended = true;
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const new = @intFromEnum(tree.TopFile.new);
+ // The dogfood's repro: Newcol, three pane/new, a Tty, then pane/new
+ // until a column has no room (ENOSPC).
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ for (0..3) |_| try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .ctl), "Tty\n").reply.status);
+ p.sync();
+ while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync();
+ p.sync();
+ var line: [32]u8 = undefined;
+ // Down a row at a time (59, 58, 57, 56 and on) until one is refused:
+ // once refused, every smaller one is too.
+ var taken: [60]bool = @splat(false);
+ var rows: u16 = 59;
+ var refused = false;
+ while (rows >= 20) : (rows -= 1) {
+ taken[rows] = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok;
+ if (refused) try testing.expect(!taken[rows]);
+ if (!taken[rows]) refused = true;
+ }
+ try testing.expect(refused); // the columns are full: some size is too small
+ // Back up: each size is taken again exactly when it was on the way down.
+ rows = 20;
+ while (rows <= 59) : (rows += 1) {
+ const ok = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok;
+ try testing.expectEqual(taken[rows], ok);
+ }
+ for ([_]u16{ 60, 61, 64, 70, 100 }) |grown| {
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{grown})).reply.status);
+ try testing.expectEqual(grown, p.screen_h);
+ }
+ // Out of range is EINVAL, in words 9ns reads as that.
+ const bad = wr(p, root_ctl, "size 5 2\n");
+ try testing.expectEqual(E.INVAL, bad.errno());
+ try testing.expectStringStartsWith(bad.reply.ename, "invalid size");
+}
+
+test "Repl with no such language names a few whole, and where the rest are" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const refused = wr(p, Node.of(serialOf(p), .ctl), "Repl zzlang\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Repl: no language \"zzlang\"; - or one like zig ada bash c c_sharp clojure (") != null);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, " more: docs/tags.md, Repl)") != null);
+}
+
+test "a 10k-line Edit text block is taken in linear time and memory" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ var block: std.ArrayList(u8) = .empty;
+ defer block.deinit(gpa);
+ try block.appendSlice(gpa, "Edit $a\n");
+ for (0..10_000) |i| try block.print(gpa, "line {d} of the block {{ with a brace }}\n", .{i});
+ try block.appendSlice(gpa, ".\n");
+ const started = std.Io.Clock.awake.now(std.testing.io);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .ctl), block.items).reply.status);
+ const took = started.durationTo(std.Io.Clock.awake.now(std.testing.io)).toNanoseconds();
+ try testing.expect(took < std.time.ns_per_s);
+ try testing.expect(p.scratch.queryCapacity() < 50 * 1024 * 1024);
+ try testing.expectEqual(@as(usize, 10_001), std.mem.count(u8, p.panes[0].?.file.?.content, "\n"));
+}
+
+test "a file changed on disk under unsaved edits is not reloaded: it says so, get asks, Save warns once" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const f = &p.panes[0].?.file.?;
+ // Clean, it takes the new text.
+ pardes.panes.File.changed(p, 0, "two\n");
+ try testing.expectEqualStrings("two\n", f.content);
+ var reloaded: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial})));
+ // Edited, it keeps its text and stays dirty.
+ _ = wr(p, Node.of(serial, .body), "mine\n");
+ pardes.panes.File.changed(p, 0, "three\n");
+ try testing.expectEqualStrings("two\nmine\n", f.content);
+ try testing.expect(f.revision != f.saved_revision);
+ try testing.expect(th.logHas(p, "changed on disk (get reloads it, Save overwrites it)"));
+ var want: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "changed {d}\n", .{serial})));
+ // Exit and get still ask about the edits.
+ try testing.expectEqual(Status.err, wr(p, @intFromEnum(tree.TopFile.ctl), "Exit\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, wr(p, Node.of(serial, .ctl), "get\n").reply.ename, "Modified") != null);
+ // Save warns once before overwriting the newer file, then writes.
+ const first = wr(p, Node.of(serial, .ctl), "Save\n");
+ try testing.expectEqual(Status.err, first.reply.status);
+ try testing.expect(std.mem.indexOf(u8, first.reply.ename, "modified on disk since read") != null);
+ try testing.expect(wr(p, Node.of(serial, .ctl), "Save\n").saved);
+}
+
+test "a looked word is found in its own pane, next place after the dot, wrapping, and opens no pane" {
+ const p = try withFile(testing.allocator, "foo x foo y\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const look_node = Node.of(serial, .look);
+ const dot = Node.of(serial, .dot);
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ for ([_][]const u8{ " 0 3 ", " 6 9 ", " 0 3 " }) |want| {
+ try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status);
+ try testing.expectEqualStrings(want, rd(p, dot, 0, 64).bytes);
+ }
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // LookWord list: the +Search listing, as before.
+ try testing.expect(p.executeBuiltinLine(0, "LookWord list"));
+ try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status);
+ after = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expect(after > count);
+ // ...and look reads back that +Search pane, where the places are.
+ var listed = false;
+ var rows = std.mem.tokenizeScalar(u8, rd(p, look_node, 0, 256).bytes, '\n');
+ while (rows.next()) |row| {
+ const made = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, row, 10)).?].?;
+ if (made.file) |f| if (f.output) |o| if (o.from == .search) {
+ listed = true;
+ };
+ }
+ try testing.expect(listed);
+}
+
+test "every word's /commands description is its own doc comment's first sentence, no citation" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const listed = rd(p, @intFromEnum(tree.TopFile.commands), 0, 1 << 16).bytes;
+ for ([_][]const u8{
+ "\nRepl arg pane -- Binds this terminal as a language's REPL, so an exec on that language's files is typed into it instead of run.\n",
+ "\nJoincol root -- Fold the active pane's column into the one on its right, keeping its panes.\n",
+ "\nExit root -- Quit the editor, refusing once while a pane holds unsaved text: an Exit with nothing edited since discards it.\n",
+ "\nColors arg root on,off -- Syntax colours in text panes, on or off.\n",
+ // fs.md's examples, as they read.
+ "\nNewcol root -- An empty column right of the keyboard's, its tag taking the keyboard.\n",
+ "\nSave arg pane -- Write the pane's text to its file, or to the file its argument names.\n",
+ "\nVerbose arg root on,off -- A builtin says its own name on the message row as it runs, on or off.\n",
+ "\nPlacement arg root acme,pardes -- Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules.\n",
+ "\nKill arg root -- acme's Kill: stop the commands pardes started",
+ }) |row| try testing.expect(std.mem.indexOf(u8, listed, row) != null);
+ try testing.expect(std.mem.indexOf(u8, listed, ".c:") == null);
+ // Every description is the first sentence of the doc comment just
+ // above its word's declaration in builtins.zig.
+ const source = @embedFile("../builtins.zig");
+ var it = std.mem.tokenizeScalar(u8, listed, '\n');
+ var undescribed: usize = 0;
+ while (it.next()) |line| {
+ // Every builtin has one: a word nobody can look up is no use.
+ const dashes = std.mem.indexOf(u8, line, " -- ") orelse {
+ std.debug.print("no doc comment: {s}\n", .{line});
+ undescribed += 1;
+ continue;
+ };
+ const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ').?];
+ if (config.Runtime.find(word)) |setting| {
+ try testing.expect(std.mem.endsWith(u8, line, setting.doc));
+ continue;
+ }
+ var needle: [64]u8 = undefined;
+ const at = std.mem.indexOf(u8, source, try std.fmt.bufPrint(&needle, "\npub const {s} = struct", .{word})).?;
+ const said = std.mem.trimEnd(u8, line[dashes + 4 ..], "…");
+ // Its opening words are in the comment block right above.
+ const head = said[0..@min(said.len, 10)];
+ const block_start = std.mem.lastIndexOf(u8, source[0..at], "\n\n") orelse 0;
+ try testing.expect(std.mem.indexOf(u8, source[block_start..at], head) != null);
+ }
+ try testing.expectEqual(0, undescribed);
+}
+
+test "Newcol at the column cap fails the write and says the cap" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 4000, .rows = 30 } });
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ while (p.ncol < pardes.MAX_COLS) {
+ // From the widest column's tag, so no column gets too narrow first.
+ p.sync();
+ var widest: usize = 0;
+ for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) {
+ widest = c;
+ };
+ pardes.tagline.enterHeader(p, widest);
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ }
+ const refused = wr(p, root_ctl, "Newcol\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ var want: [64]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, try std.fmt.bufPrint(&want, "no space for a column: {d} max", .{pardes.MAX_COLS})) != null);
+ try testing.expect(th.logHas(p, "no space for a column"));
+}
+
+test "Save on a terminal asked over 9P is an ask in the log, answered with a path, and not asked twice" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Save\n").reply.status);
+ var want: [48]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} save path\n", .{serial})));
+ try testing.expect(p.panes[id].?.prompt == .save);
+ // Again: the one open stands, one ask in the log.
+ _ = wr(p, Node.of(serial, .tagexec), "Save\n");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, "save path\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ // Answered: the path is written to, the prompt goes.
+ const answered = wr(p, Node.of(serial, .ctl), "answer /tmp/pardes-ask-save.txt\n");
+ try testing.expectEqual(Status.ok, answered.reply.status);
+ try testing.expect(p.panes[id].?.prompt == .none);
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} /tmp/pardes-ask-save.txt\n", .{serial})));
+}
+
+test "get names the path it could not read, logs the edits it threw away, and a deleted file is said once" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const f = &p.panes[0].?.file.?;
+ const path = f.path;
+ // Its file is not on disk: get names it.
+ const missing = wr(p, Node.of(serial, .ctl), "get\n");
+ try testing.expectEqual(E.NOENT, missing.errno());
+ var want: [512]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "get: {s}: no such file", .{path}), missing.reply.ename);
+ // Deleted on disk: said once.
+ pardes.panes.File.deleted(p, 0);
+ pardes.panes.File.deleted(p, 0);
+ var rec: [32]u8 = undefined;
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, try std.fmt.bufPrint(&rec, "changed {d} deleted\n", .{serial})));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "a builtin that fails through exec, tagexec or a column's exec fails the write with one err and no msg" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ const serial = serialOf(p);
+ for ([_]u64{ root_exec, Node.of(serial, .exec), @intFromEnum(tree.TopFile.tagexec), Node.ofCol(pardes.layout.columnSerial(p, 0), .exec) }) |node| {
+ const failed = wr(p, node, "Kill zzz\n");
+ try testing.expectEqual(Status.err, failed.reply.status);
+ try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Kill: no running command has that first word") != null);
+ }
+ 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.indexOf(u8, text, "msg ") == null);
+ // The root's exec ran at the pane, so its err and the pane's exec's are
+ // the same record, counted.
+ try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Kill: no running command has that first word"));
+ try testing.expect(std.mem.indexOf(u8, text, "first word (x2)") != null);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ // A control character is refused with its reason.
+ try testing.expectEqualStrings(e_control, wr(p, root_exec, "Msg a\x01b\n").reply.ename);
+ // A look miss: one err, no msg.
+ _ = wr(p, @intFromEnum(tree.TopFile.look), "zzqqnotthere\n");
+ try testing.expect(th.logHas(p, "look: no match for \"zzqqnotthere\""));
+ try testing.expect(!th.logHas(p, "msg 1 Look: no match"));
+}
+
+test "a word written to a raw terminal's look is listed as in normal mode" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "one MARK two\r\n" } });
+ p.panes[id].?.body.mode = .tty;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "MARK\n").reply.status);
+ const said = std.mem.trimEnd(u8, rd(p, Node.of(serial, .look), 0, 64).bytes, "\n");
+ const made = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, said, 10)).?].?;
+ try testing.expect(made.file.?.output.?.from == .search);
+}
+
+test "@p<serial>:<address> takes any sam address; a miss and an unknown serial are errs" {
+ const p = try withFile(testing.allocator, "foo x foo y\nbar\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const root = @intFromEnum(tree.TopFile.look);
+ const dot = Node.of(serial, .dot);
+ const Case = struct { addr: []const u8, want: []const u8 };
+ for ([_]Case{
+ .{ .addr = "0/foo/", .want = " 0 3 " },
+ .{ .addr = "/foo/", .want = " 6 9 " },
+ .{ .addr = "#12,#15", .want = " 12 15 " },
+ .{ .addr = "$", .want = " 16 16 " },
+ .{ .addr = "1,2", .want = " 0 16 " },
+ // Backward, as in addr: from the dot's start, wrapping to the last.
+ .{ .addr = "?foo?", .want = " 6 9 " },
+ .{ .addr = "$-?y?", .want = " 10 11 " },
+ }) |c| {
+ var line: [64]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:{s}\n", .{ serial, c.addr })).reply.status);
+ try testing.expectEqualStrings(c.want, rd(p, dot, 0, 64).bytes);
+ }
+ var line: [64]u8 = undefined;
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial}));
+ try testing.expect(th.logHas(p, "look: no match for regexp"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+ _ = wr(p, root, "@p999:/foo/\n");
+ try testing.expect(th.logHas(p, "look: no pane @p999"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+ // A line address to no pane is a miss too, not the active pane.
+ _ = wr(p, root, "@p77:1\n");
+ try testing.expect(th.logHas(p, "look: no pane @p77"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+}
+
+test "a look that goes to another pane leaves the looking pane's dot alone" {
+ const p = try withFile(testing.allocator, "alpha bravo\ncharlie\n");
+ defer p.deinit();
+ const source = serialOf(p);
+ const other = try th.newPane(p);
+ _ = wr(p, Node.of(other, .body), "one\ntwo\nthree\n");
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f one\nf two\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;
+ for ([_][]const u8{ "@p{d}:2", "@p{d}:/three/", "{s}/f.txt:2" }) |form| {
+ @import("pane.zig").setDot(p.panes[p.paneBySerial(source).?].?, .{ .q0 = 6, .q1 = 11 });
+ const text = if (form[0] == '@')
+ (if (std.mem.eql(u8, form, "@p{d}:2")) try std.fmt.bufPrint(&line, "@p{d}:2\n", .{other}) else try std.fmt.bufPrint(&line, "@p{d}:/three/\n", .{other}))
+ else
+ try std.fmt.bufPrint(&line, "{s}/f.txt:2\n", .{dir});
+ try testing.expectEqual(Status.ok, wr(p, Node.of(source, .look), text).reply.status);
+ try testing.expect(p.active != p.paneBySerial(source).?);
+ try testing.expectEqualStrings(" 6 11 ", rd(p, Node.of(source, .dot), 0, 64).bytes);
+ }
+}
+
+test "@p<terminal>:<address> takes any address over the terminal's lines, selecting the match" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "one\r\ntwo MARK three\r\n" } });
+ const pane = p.panes[id].?;
+ const root = @intFromEnum(tree.TopFile.look);
+ var line: [64]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:0/MARK/\n", .{serial})).reply.status);
+ try testing.expectEqual(@as(i32, 1), pane.body.cur_row);
+ try testing.expect(pane.body.vsel.active);
+ // #n lands on that byte: #5 is the `w` of `two`.
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:#5\n", .{serial})).reply.status);
+ try testing.expectEqual(@as(i32, 1), pane.body.cur_row);
+ 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"));
+}