summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/addr.zig188
-rw-r--r--src/ninep/cols.zig124
-rw-r--r--src/ninep/ctl.zig847
-rw-r--r--src/ninep/events.zig322
-rw-r--r--src/ninep/pane.zig146
-rw-r--r--src/ninep/pty.zig49
-rw-r--r--src/ninep/tree.zig309
7 files changed, 1792 insertions, 193 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 1e47183b..9498df19 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -13,6 +13,7 @@ fn clip(n: usize) u32 {
pub const e_no_match = "no match for regexp";
pub const e_range = "address out of range";
+pub const e_col_zero = "address out of range: a column counts from 1";
pub const e_regexp = "bad regular expression";
/// Not "took too long": 9ns reads errors by their words, and that would be
/// ENAMETOOLONG.
@@ -94,7 +95,7 @@ pub const Addr = struct {
// pardes's `line:col`, Look's `file:12:5` spelling (look.zig
// parsePathLine): the point at that column of that line, the
// column in bytes from 1, clamped to the line's end and snapped
- // to a character's start as Look's cursor is. Not sam's.
+ // to a rune's start, as every address is. Not sam's.
if (size == .line and dir == 0 and a.i + 1 < a.expr.len and a.expr[a.i] == ':' and
a.expr[a.i + 1] >= '0' and a.expr[a.i + 1] <= '9')
{
@@ -104,10 +105,10 @@ pub const Addr = struct {
col = @min(col * 10 + (a.expr[a.i] - '0'), std.math.maxInt(u32));
const lines = modal.cursorLineCount(a.text);
if (n == 0 or n > lines or col == 0) {
- a.err = e_range;
+ a.err = if (col == 0) e_col_zero else e_range;
return null;
}
- const q = clip(modal.offsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
+ const q = clip(modal.runeOffsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
r = .{ .q0 = q, .q1 = q };
continue;
}
@@ -162,7 +163,7 @@ pub const Addr = struct {
a.err = e_range;
return null;
}
- const g = clip(modal.graphemeStart(a.text, @intCast(off)));
+ const g = clip(modal.runeStart(a.text, @intCast(off)));
return .{ .q0 = g, .q1 = g };
}
var line: i64 = n;
@@ -188,35 +189,81 @@ pub const Addr = struct {
while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1;
return .{ .q0 = clip(q0), .q1 = clip(q1) };
},
- '+' => {
- if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') {
- q1 += 1;
- };
- q0 = q1;
- },
- else => {
- q0 = 0;
- q1 = 0;
- },
+ else => {},
}
- while (line > 0 and q1 < a.text.len) {
- const ch = a.text[q1];
- q1 += 1;
- if (ch == '\n' or q1 == a.text.len) {
- line -= 1;
- if (line > 0) q0 = q1;
+ // Forward, sam's lineaddr (editors/sam/address.c:180-215), not acme's
+ // number(): line n is found by counting newlines, and a line just
+ // past the last newline is an empty one there, so `1` of an empty
+ // text is #0,#0 and `2` of "a\n" is #2,#2; only past that is it out
+ // of range.
+ const nc = a.text.len;
+ var p: usize = 0;
+ var p1: usize = 0;
+ if (n == 0) {
+ if (dir == 0 or q1 == 0) return .{ .q0 = 0, .q1 = 0 };
+ p1 = q1;
+ p = q1 - 1;
+ } else {
+ var k: u32 = 1;
+ if (dir != 0 and q1 > 0) {
+ p = q1 - 1;
+ k = @intFromBool(a.text[p] == '\n');
+ p += 1;
+ }
+ while (k < n) {
+ if (p >= nc) {
+ a.err = e_range;
+ return null;
+ }
+ if (a.text[p] == '\n') k += 1;
+ p += 1;
}
+ p1 = p;
}
- if (line > 0) {
- a.err = e_range;
- return null;
+ while (p < nc) {
+ const ch = a.text[p];
+ p += 1;
+ if (ch == '\n') break;
}
- return .{ .q0 = clip(q0), .q1 = clip(q1) };
+ return .{ .q0 = clip(p1), .q1 = clip(p) };
}
/// acme's regexp(): forward from the end of `r` to the limit, wrapping
/// to the start of the text when there is none; backward, the last
/// match ending by the start of `r`, else the last one in the text.
+ /// The first match at or after `from`, else, with no limit, the first
+ /// from the top (a search wraps); null (the outer) when too slow.
+ fn forward(a: *Addr, rx: *regexp_.Regex, from: usize, hi: usize) ??Range {
+ const ahead = rx.find(a.text, from, hi, hi) catch {
+ a.err = e_slow;
+ return null;
+ };
+ const m = ahead orelse (if (a.lim != null or from == 0) null else rx.find(a.text, 0, from - 1, hi) catch {
+ a.err = e_slow;
+ return null;
+ }) orelse return @as(?Range, null);
+ return @as(?Range, .{ .q0 = clip(m.start), .q1 = clip(m.end) });
+ }
+
+ /// The last match ending at or before `limit`, else the last of all
+ /// (a search wraps); null (the outer) when too slow.
+ fn backward(a: *Addr, rx: *regexp_.Regex, limit: u32) ??Range {
+ var last: ?Range = null;
+ var before: ?Range = null;
+ var at: usize = 0;
+ while (at <= a.text.len) {
+ const m = (rx.find(a.text, at, a.text.len, a.text.len) catch {
+ a.err = e_slow;
+ return null;
+ }) orelse break;
+ const found_range: Range = .{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ if (found_range.q1 <= limit) before = found_range;
+ last = found_range;
+ at = if (m.end > m.start) m.end else m.end + 1;
+ }
+ return @as(?Range, before orelse last);
+ }
+
fn regexp(a: *Addr, r: Range, pat: []const u8, back: bool) ?Range {
if (pat.len == 0) {
a.err = "no previous regular expression";
@@ -226,34 +273,29 @@ pub const Addr = struct {
a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp;
return null;
};
+ // sam's nextmatch (editors/sam/address.c:97-119): an empty match
+ // right at the search's start is no progress, so the search is made
+ // again a character on (back: a character before), wrapping, and
+ // repeated `/^/` or `/$/` walks the lines.
const found = if (back) found: {
- var last: ?Range = null;
- var before: ?Range = null;
- var at: usize = 0;
- while (at <= a.text.len) {
- const m = (rx.find(a.text, at, a.text.len, a.text.len) catch {
- a.err = e_slow;
- return null;
- }) orelse break;
- const found_range: Range = .{ .q0 = clip(m.start), .q1 = clip(m.end) };
- if (found_range.q1 <= r.q0) before = found_range;
- last = found_range;
- at = if (m.end > m.start) m.end else m.end + 1;
- }
- break :found before orelse last;
+ var f = a.backward(&rx, r.q0) orelse return null;
+ if (f) |m| if (m.q0 == m.q1 and m.q1 == r.q0) {
+ f = a.backward(&rx, if (r.q0 == 0) clip(a.text.len) else r.q0 - 1) orelse return null;
+ };
+ break :found f;
} else found: {
const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len;
const from = @min(@as(usize, r.q1), hi);
- const ahead = rx.find(a.text, from, hi, hi) catch {
- a.err = e_slow;
- return null;
+ var f = a.forward(&rx, from, hi) orelse return null;
+ if (f) |m| if (m.q0 == m.q1 and m.q0 == from) {
+ const next = if (from + 1 > hi) (if (a.lim != null) break :found null else 0) else from + 1;
+ f = a.forward(&rx, next, hi) orelse return null;
};
- const m = ahead orelse (if (a.lim != null or from == 0) null else rx.find(a.text, 0, from - 1, hi) catch {
- a.err = e_slow;
- return null;
- }) orelse break :found null;
- break :found Range{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ break :found f;
};
+ // A byte pattern may match inside a rune: the match covers the
+ // runes it touches.
+ if (found) |f| return .{ .q0 = clip(modal.runeStart(a.text, f.q0)), .q1 = clip(modal.runeEnd(a.text, f.q1)) };
return found orelse {
a.err = e_no_match;
return null;
@@ -306,6 +348,27 @@ test "an address is evaluated from the current one, as acme's are" {
try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
}
+test "an empty match at the search's start is passed over, as sam's nextmatch does" {
+ const p = try th.withFile(testing.allocator, "ab\ncd\nef\n");
+ defer p.deinit();
+ const addr = Node.of(th.serialOf(p), .addr);
+ const Case = struct { start: []const u8, expr: []const u8, want: []const u32 };
+ for ([_]Case{
+ // From #3 (the start of line 2), /^/ goes on to line 3.
+ .{ .start = "#3", .expr = "/^/", .want = &.{6} },
+ .{ .start = "#2", .expr = "/$/", .want = &.{ 5, 8 } },
+ .{ .start = "#3", .expr = "/x*/", .want = &.{ 4, 5, 6 } },
+ .{ .start = "#3", .expr = "?^?", .want = &.{0} },
+ }) |c| {
+ _ = th.wr(p, addr, c.start);
+ for (c.want) |q| {
+ try testing.expectEqual(Status.ok, th.wr(p, addr, c.expr).reply.status);
+ try testing.expectEqual(q, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(q, p.panes[0].?.fs.addr.q1);
+ }
+ }
+}
+
test "regular expressions search lines as sam's do, and a search wraps" {
const p = try th.withFile(testing.allocator, "alpha beta\nbeta gamma\ngamma\n");
defer p.deinit();
@@ -357,7 +420,8 @@ test "a failed address leaves none, so data refuses rather than act at the last
try testing.expectEqualStrings(e_no_match, th.wr(p, addr, "/zzz/").reply.ename);
try testing.expectEqualStrings(pane_files.e_addr_failed, th.wr(p, data, "ONE").reply.ename);
try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, data, 0, 64).reply.ename);
- try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, addr, 0, 64).reply.ename);
+ try testing.expectEqual(Status.ok, th.rd(p, addr, 0, 64).reply.status);
+ try testing.expectEqualStrings("", th.rd(p, addr, 0, 64).bytes);
try testing.expectEqual(E.INVAL, th.call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .xdata), .truncate = true }).errno());
try testing.expectEqualStrings("one\ntwo\n", p.panes[0].?.file.?.content);
// A good address gives it one again; truncating addr does not.
@@ -466,7 +530,7 @@ test "the address language, form by form" {
for ([_][2][]const u8{
.{ "zzz", e_syntax }, .{ "/nomatch/", e_no_match }, .{ "99", e_range },
.{ "#999", e_range }, .{ "/a[/", e_regexp }, .{ "/(a/", e_regexp },
- .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_range },
+ .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_col_zero },
}) |c| {
_ = th.wr(p, addr, "#0");
try testing.expectEqualStrings(c[1], th.wr(p, addr, c[0]).reply.ename);
@@ -476,3 +540,31 @@ test "the address language, form by form" {
_ = th.wr(p, addr, "#0");
try testing.expectEqual(E.INVAL, th.wr(p, addr, nested).errno());
}
+
+test "a line just past the last newline is an empty one there, as sam's lineaddr has it" {
+ const Case = struct { text: []const u8, expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .text = "", .expr = "1", .q0 = 0, .q1 = 0 },
+ .{ .text = "a\n", .expr = "2", .q0 = 2, .q1 = 2 },
+ .{ .text = "a\nb", .expr = "2", .q0 = 2, .q1 = 3 },
+ .{ .text = "a\nb\n", .expr = "1", .q0 = 0, .q1 = 2 },
+ .{ .text = "a\nb\n", .expr = "0", .q0 = 0, .q1 = 0 },
+ .{ .text = "a\nb\n", .expr = "1+1", .q0 = 2, .q1 = 4 },
+ .{ .text = "a\nb\n", .expr = "$", .q0 = 4, .q1 = 4 },
+ }) |c| {
+ var a: Addr = .{ .text = c.text, .lim = null, .expr = c.expr };
+ const r = a.address(.{ .q0 = 0, .q1 = 0 }).?;
+ try testing.expectEqual(c.q0, r.q0);
+ try testing.expectEqual(c.q1, r.q1);
+ }
+ for ([_][2][]const u8{ .{ "", "2" }, .{ "a\n", "3" } }) |c| {
+ var a: Addr = .{ .text = c[0], .lim = null, .expr = c[1] };
+ try testing.expectEqual(null, a.address(.{ .q0 = 0, .q1 = 0 }));
+ try testing.expectEqualStrings(e_range, a.err);
+ }
+ // Edit's addresses are these: 1i on an empty file inserts.
+ const p = try th.withFile(testing.allocator, "");
+ defer p.deinit();
+ try testing.expectEqual(Status.ok, th.wr(p, Node.of(th.serialOf(p), .ctl), "Edit 1i/header/\n").reply.status);
+ try testing.expectEqualStrings("header", p.panes[0].?.file.?.content);
+}
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index b6dc7d26..b00ecdba 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -123,8 +123,11 @@ pub fn released(p: *Pardes, serial: ?u32) void {
}
/// A column's ctl: the words that act on a column, as its tag's clicks do --
-/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag.
+/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag. What
+/// they say is the session's in the log (`msg -`), not the focused pane's.
pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
+ p.fs.session_write = true;
+ defer p.fs.session_write = false;
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
@@ -133,31 +136,59 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
if (std.mem.eql(u8, w, word)) break true;
} else false;
- if (!takes) return tree.failText(req.tag, E.INVAL, "a column's ctl takes Delcol, Joincol, New or Tty");
+ // Plan 9's words for it, so a mount's errno is EINVAL as the root's
+ // and a pane's ctl give (9ns reads "control message").
+ if (!takes) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "unknown control message \"{s}\": a column's ctl takes Delcol, Joincol, New or Tty", .{line[0..@min(line.len, 64)]}) catch "unknown control message");
+ // Only Tty takes a word (its shell); `New extra` is no New.
+ if (!std.mem.eql(u8, word, "Tty") and word.len != line.len)
+ return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "wrong #args in control message \"{s}\"", .{line[0..@min(line.len, 64)]}) catch "wrong #args in control message");
const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
p.exec_column = col;
defer p.exec_column = null;
+ p.exec_header = true;
+ defer p.exec_header = false;
if (pardes.ctlfs.ctl.runBuiltin(p, req, p.active, line)) |refusal| return refusal;
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
-/// A column's exec: each line run as a middle click on it in that column's
-/// tag, acting on that column.
-pub fn writeExec(p: *Pardes, req: Req, serial: u32) Reply {
+/// A column's exec (`serial`), or the workspace tag's (/tagexec, null):
+/// each line run as a middle click on it in that tag, a column's acting on
+/// that column. A read answers the panes the last write touched, as /exec's
+/// does; what the words say is the session's in the log (`msg -`).
+pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply {
+ p.fs.results_len = 0;
+ p.fs.session_write = true;
+ defer p.fs.session_write = false;
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
- for (line) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
- const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control);
+ if (pardes.ctlfs.ctl.tooLong(req, line)) |refusal| return refusal;
+ const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null;
+ if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
p.exec_column = col;
defer p.exec_column = null;
- pardes.ctlfs.ctl.run(p, p.active, true, line);
+ p.exec_header = true;
+ defer p.exec_header = false;
+ if (pardes.ctlfs.ctl.captured(p, req, p.active, true, line, true)) |refusal| return refusal;
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+test "New written to a column's exec makes its scratch in the session's directory, as tagexec's does" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const session = "/project/src";
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = session.len;
+ const serial = layout.columnSerial(p, 0);
+ const exec_node = tree.Node.ofCol(serial, .exec);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, exec_node, "New\n").reply.status);
+ try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path);
+}
+
/// rmdir of a column closes it when it is empty; one with panes is
/// refused, saying so (close them, or Delcol on its ctl, which asks first).
pub fn remove(p: *Pardes, req: Req, serial: u32, file: tree.ColFile) Reply {
@@ -257,10 +288,12 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .ctl), "New\n").reply.status);
try testing.expectEqual(@as(usize, 1), p.col_n[1]);
try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno());
+ var rec: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}/ctl: unknown control message \"Exit\"", .{empty})));
// rmdir refuses a column with a pane, and says why.
const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true });
try testing.expectEqual(E.NOTEMPTY, full.errno());
- try testing.expect(th.logHas(p, "column not empty"));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty})));
// exec runs a word as a click in that column's tag: Delcol closes it.
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status);
try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty));
@@ -282,3 +315,76 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on
// The root's ctl points a column word at the column's own.
try testing.expect(std.mem.endsWith(u8, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Delcol\n").reply.ename, "write it to col/<serial>/ctl"));
}
+
+test "tagexec runs a word as a click in the workspace tag; exec files read back what they touched; their messages are the session's" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const tagexec = @intFromEnum(tree.TopFile.tagexec);
+ try testing.expectEqual(tree.Status.ok, th.look_up(p, tree.root, "tagexec").reply.status);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status);
+ try testing.expectEqual(@as(usize, 2), p.ncol);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Msg from the top\n").reply.status);
+ try testing.expect(th.logHas(p, "msg - from the top\n"));
+ // A column's exec makes a pane there, reads it back, and its Msg is
+ // the session's too.
+ const right = layout.columnSerial(p, 1);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "New\n").reply.status);
+ const made = p.panes[p.col_panes[1][0]].?.serial;
+ var want: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, Node.ofCol(right, .exec), 0, 64).bytes);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, tagexec, 0, 64).bytes);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "Msg from a column\n").reply.status);
+ try testing.expect(th.logHas(p, "msg - from a column\n"));
+}
+
+test "a word run from the workspace's or a column's tag runs in the session's directory, a pane's in its own" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const session = p.sessionDir();
+ try testing.expect(!std.mem.eql(u8, session, pardes.Pardes.paneDir(p.panes[0].?))); // the file is elsewhere
+ const dirs = [_]u64{ @intFromEnum(tree.TopFile.tagexec), Node.ofCol(layout.columnSerial(p, 0), .exec) };
+ for (dirs) |node| {
+ try testing.expectEqual(tree.Status.ok, th.wr(p, node, "true\n").reply.status);
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqualStrings(session, pardes.Pardes.paneDir(p.panes[made].?));
+ try p.removePane(made, null);
+ }
+ const from_pane = p.panes[0].?.serial;
+ _ = th.wr(p, Node.of(from_pane, .exec), "true\n");
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqualStrings(pardes.Pardes.paneDir(p.panes[0].?), pardes.Pardes.paneDir(p.panes[made].?));
+}
+
+test "a command run from a column's tag gets its pane in that column, not the last" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.newColumn(p.active);
+ p.newScratchFor(p.active, .fs); // fills the new, right column
+ p.sync();
+ const left = layout.columnSerial(p, 0);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(left, .exec), "true\n").reply.status);
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqual(@as(usize, 0), layout.findPane(p, made).?.col);
+ // Finished, it is reused only by its own column's commands.
+ p.panes[made].?.command_done = true;
+ p.panes[made].?.command_pty = false;
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(layout.columnSerial(p, 1), .exec), "true\n").reply.status);
+ const other = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expect(other != made);
+ try testing.expectEqual(@as(usize, 1), layout.findPane(p, other).?.col);
+}
+
+test "a column's ctl refuses an unknown word and an argument as the other ctls do" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const ctl_node = Node.ofCol(layout.columnSerial(p, 0), .ctl);
+ const unknown = th.wr(p, ctl_node, "Bogus\n");
+ try testing.expectEqual(E.INVAL, unknown.errno());
+ try testing.expect(std.mem.startsWith(u8, unknown.reply.ename, "unknown control message \"Bogus\""));
+ const extra = th.wr(p, ctl_node, "New extra\n");
+ try testing.expectEqual(E.INVAL, extra.errno());
+ try testing.expect(std.mem.startsWith(u8, extra.reply.ename, "wrong #args"));
+ try testing.expectEqual(@as(usize, 1), p.col_n[0]);
+ // A pane's bare answer and a winsize of no size say what they take.
+ try testing.expectEqualStrings("answer takes a choice or -", th.wr(p, Node.of(p.panes[0].?.serial, .ctl), "answer\n").reply.ename);
+}
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"));
+}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index ccc61352..98799774 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -110,7 +110,9 @@ pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply {
// ---- the editor-wide log ----
-pub const LogKind = enum { new, del, rename, save };
+/// `unsaved`: a pane a refusal (Exit, Restore, Del, Delcol) is about,
+/// logged before the write's err so a script reads which, whole.
+pub const LogKind = enum { new, del, rename, save, unsaved };
/// A pane leaving, before its state goes with it; one never announced
/// leaves silently.
@@ -122,6 +124,12 @@ pub fn noteRetire(p: *Pardes, pane: *Pane) void {
pane.fs.unannounced = false;
return;
}
+ // A question still standing ends with it, so a follower of its `ask`
+ // hears the end.
+ if (pane.prompt == .repl_choice or pane.prompt == .del_side or pane.prompt == .save) {
+ var rec: [32]u8 = undefined;
+ notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} -", .{pane.serial}) catch "");
+ }
noteLog(p, .del, pane);
}
@@ -143,12 +151,20 @@ pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
}
/// Records `msg <serial> <text>` for what the editor said, `-` for no pane.
-pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void {
+/// Text past LoggedMessage.cap bytes is cut between words with an ellipsis,
+/// as an err record's reason is.
+pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
var buf: [pardes.Messages.LoggedMessage.cap + 32]u8 = undefined;
- pushLog(p, (if (serial == 0)
- std.fmt.bufPrint(&buf, "msg - {s}\n", .{text})
+ const kept = pardes.Messages.clip(said, pardes.Messages.LoggedMessage.cap);
+ const cut = if (kept.len < said.len) "…" else "";
+ // A builtin's own word for what it did stands in for its Verbose
+ // announcement just before (`Undo` then `Undo: nothing to undo`), so
+ // that 1000 Undos with nothing left are one line, counted, as err's are.
+ dropAnnouncement(p, serial, kept);
+ pushCounting(p, (if (serial == 0)
+ std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut })
else
- std.fmt.bufPrint(&buf, "msg {d} {s}\n", .{ serial, text })) catch return);
+ std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return);
}
/// Records `err <serial|-> <file>: <why>` for a write or truncation that was
@@ -165,16 +181,25 @@ pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void {
pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
const why = if (reply.ename.len > 0) reply.ename else cloud9.fs.errString(reply.errno);
- var name: [16]u8 = undefined;
+ var name: [32]u8 = undefined;
var serial: []const u8 = "-";
var file: []const u8 = "os";
if (req.node & tree.sources.archive_node != 0) file = "src";
if (req.node != pardes.filesystem.os_root and req.node & pardes.filesystem.os_node == 0 and req.node & tree.sources.archive_node == 0) {
if (tree.Node.target(req.node)) |t| switch (t) {
- .top => |f| file = f.fileName(),
+ .top => |f| {
+ file = f.fileName();
+ // The root's exec or look ran at the keyboard's pane: its
+ // failure is that pane's, as a click there would be.
+ if ((f == .exec or f == .look) and p.fs.ran_at != 0)
+ serial = std.fmt.bufPrint(&name, "{d}", .{p.fs.ran_at}) catch "-";
+ },
.col => |c| {
serial = "-";
- file = std.fmt.bufPrint(&name, "col/{d}/tag", .{c.serial}) catch "col";
+ file = (if (c.file == .dir)
+ std.fmt.bufPrint(&name, "col/{d}", .{c.serial})
+ else
+ std.fmt.bufPrint(&name, "col/{d}/{s}", .{ c.serial, @tagName(c.file) })) catch "col";
},
.pane => |pt| {
serial = std.fmt.bufPrint(&name, "{d}", .{pt.serial}) catch "-";
@@ -189,26 +214,91 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
};
}
var buf: [256]u8 = undefined;
- const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}\n", .{ serial, file, why[0..@min(why.len, 200)] }) catch return;
+ // A long reason is cut between words, with an ellipsis, never mid-word.
+ const kept = pardes.Messages.clip(why, 200);
+ const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}{s}\n", .{ serial, file, kept, if (kept.len < why.len) "…" else "" }) catch return;
for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') {
c.* = ' ';
};
+ pushCounting(p, record);
+}
+
+/// Drops the newest record when it is the Verbose announcement (`msg
+/// <serial> <Word>`) of the builtin whose words `text` are (`Word: ...`):
+/// what the builtin said, or the err its failure logs, takes its place.
+/// Not one a follower has read.
+pub fn dropAnnouncement(p: *Pardes, serial: u32, text: []const u8) void {
+ const last = newest(p) orelse return;
+ if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return;
+ const rest = last.text["msg ".len..];
+ const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
+ const word = rest[@min(sp + 1, rest.len)..];
+ var who: [16]u8 = undefined;
+ const mine = if (serial == 0) "-" else std.fmt.bufPrint(&who, "{d}", .{serial}) catch "";
+ if (std.mem.eql(u8, rest[0..sp], mine) and word.len > 0 and std.mem.indexOfScalar(u8, word, ' ') == null and
+ text.len > word.len and std.mem.startsWith(u8, text, word) and text[word.len] == ':')
+ p.fs.log.buf.shrinkRetainingCapacity(last.at);
+}
+
+/// The sequence number of the newest log record when it is a msg that
+/// ends in `word` alone: not one counted (`(x2)`) over an earlier say.
+pub fn newestSaying(p: *Pardes, word: []const u8) ?u64 {
+ const last = newest(p) orelse return null;
+ if (!std.mem.startsWith(u8, last.text, "msg ") or !std.mem.endsWith(u8, last.text, word)) return null;
+ if (last.text[last.text.len - word.len - 1] != ' ') return null;
+ return last.seq;
+}
+
+/// Takes record `seq` back out of the log, unless a follower has read it.
+pub fn dropRecord(p: *Pardes, seq: u64) void {
+ if (followerRead(p, seq)) return;
+ const q = &p.fs.log;
+ var at = q.head;
+ var s = q.dropped;
+ while (at + 4 <= q.buf.items.len) : (s += 1) {
+ const size = 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
+ if (s == seq) {
+ const items = q.buf.items;
+ std.mem.copyForwards(u8, items[at..], items[at + size ..]);
+ q.buf.shrinkRetainingCapacity(items.len - size);
+ return;
+ }
+ at += size;
+ }
+}
+
+/// A builtin's failure: its Verbose announcement goes from the log, when
+/// `text` is that builtin's (it starts with its word) or `any`, as for a
+/// failure said while the builtin itself runs.
+pub fn dropFailedAnnouncement(p: *Pardes, text: []const u8, any: bool) void {
+ const seq = p.fs.announced_seq orelse return;
+ const word = p.fs.announced_word[0..p.fs.announced_word_len];
+ if (!any and !(std.mem.startsWith(u8, text, word) and text.len > word.len and (text[word.len] == ' ' or text[word.len] == ':'))) return;
+ p.fs.announced_seq = null;
+ dropRecord(p, seq);
+}
+
+/// Drops the newest record when it is a msg saying `text` (of any pane)
+/// that no follower has read: a failure its write's err record will say.
+pub fn dropMessage(p: *Pardes, text: []const u8) void {
+ const last = newest(p) orelse return;
+ if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return;
+ const rest = last.text["msg ".len..];
+ const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse return;
+ if (std.mem.eql(u8, rest[sp + 1 ..], text)) p.fs.log.buf.shrinkRetainingCapacity(last.at);
+}
+
+/// Pushes `record`, or, when it repeats the newest record word for word,
+/// counts that one instead: `<record> (x2)`, the count being every time it
+/// was said. One a follower has already read is not rewritten: the repeat
+/// is a new line carrying the running count, `(x3)`.
+fn pushCounting(p: *Pardes, record: []u8) void {
// A client retrying a write that fails the same way would fill the ring
// with one line, so a repeat of the newest record is that record counted,
// `(x3)`, as +Messages counts its repeats; unless a follower has read it
// already and so waits on the repeat as a line of its own.
- const q = &p.fs.log;
- var at = q.head;
- var last: ?usize = null;
- var seq = q.dropped;
- while (at + 4 <= q.buf.items.len) : (seq += 1) {
- last = at;
- at += 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
- }
- const last_at = last orelse return pushLog(p, record);
- const last_seq = seq - 1;
- var said = q.buf.items[last_at + 4 ..];
- said = said[0 .. said.len - 1];
+ const last = newest(p) orelse return pushLog(p, record);
+ var said = last.text;
var times: u32 = 1;
if (std.mem.endsWith(u8, said, ")")) if (std.mem.lastIndexOf(u8, said, " (x")) |x| {
if (std.fmt.parseInt(u32, said[x + 3 .. said.len - 1], 10)) |n| {
@@ -217,14 +307,35 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
} else |_| {}
};
if (!std.mem.eql(u8, said, record[0 .. record.len - 1])) return pushLog(p, record);
+ var counted: [pardes.Messages.LoggedMessage.cap + 64]u8 = undefined;
+ const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return;
+ if (!followerRead(p, last.seq)) p.fs.log.buf.shrinkRetainingCapacity(last.at);
+ pushLog(p, again);
+}
+
+/// The newest record in the ring: where it starts, its sequence number and
+/// its text without the newline.
+fn newest(p: *Pardes) ?struct { at: usize, seq: u64, text: []const u8 } {
+ const q = &p.fs.log;
+ var at = q.head;
+ var last: ?usize = null;
+ var seq = q.dropped;
+ while (at + 4 <= q.buf.items.len) : (seq += 1) {
+ last = at;
+ at += 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
+ }
+ const last_at = last orelse return null;
+ const text = q.buf.items[last_at + 4 ..];
+ return .{ .at = last_at, .seq = seq - 1, .text = text[0 .. text.len - 1] };
+}
+
+/// Whether a follower has read the record `seq`, or is part way through it.
+fn followerRead(p: *Pardes, seq: u64) bool {
for (p.fs.opens) |o| if (o.node != 0 and o.what == .log) {
const l = o.what.log;
- if (l.follow and (l.next > last_seq or (l.next == last_seq and l.part > 0))) return pushLog(p, record);
+ if (l.follow and (l.next > seq or (l.next == seq and l.part > 0))) return true;
};
- var counted: [280]u8 = undefined;
- const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return;
- q.buf.shrinkRetainingCapacity(last_at);
- pushLog(p, again);
+ return false;
}
/// The log is one ring that records whether or not anyone reads it. A record
@@ -322,7 +433,12 @@ pub fn readLog(p: *Pardes, req: Req) Reply {
/// records instead of ending; closing it is the way back.
pub fn writeLog(p: *Pardes, req: Req) Reply {
const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.log;
- if (!std.mem.eql(u8, std.mem.trim(u8, req.data, " \t\r\n"), "follow")) return Reply.fail(req.tag, E.INVAL);
+ const word = std.mem.trim(u8, req.data, " \t\r\n");
+ // `follow` reads what the open froze, then waits; `follow new` skips
+ // what was there and waits for what comes after, as tail -n0 -f does.
+ if (std.mem.eql(u8, word, "follow new")) {
+ slot.pos = slot.bytes.len;
+ } else if (!std.mem.eql(u8, word, "follow")) return Reply.fail(req.tag, E.INVAL);
slot.follow = true;
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -459,6 +575,16 @@ pub fn noteAction(
return true;
}
+/// A record that follows another with no place of its own, `<c>0 0 0 <n>
+/// <text>`: a chorded exec's argument and its location (acme exec.c:182).
+pub fn noteFollow(p: *Pardes, id: usize, action: Action, text: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ if (pane.fs.readers == 0) return;
+ var buf: [max_record_text + 64]u8 = undefined;
+ pane.fs.events.push(p.gpa, formatRecord(&buf, p.fs.origin, action, 0, 0, 0, text));
+ p.fs.news = true;
+}
+
pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
if (id >= MAX_PANES or bytes.len == 0) return;
const pf = &(p.panes[id] orelse return).fs;
@@ -472,7 +598,7 @@ pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
p.fs.news = true;
}
-const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, text: ?[]const u8 = null };
+const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, flag: u32 = 0, text: ?[]const u8 = null };
const EventReader = struct {
data: []const u8,
@@ -493,8 +619,9 @@ const EventReader = struct {
// is taken too, so a click with no place in the text (one written
// to a pane's own look or exec) can be written back and done.
var text: ?[]const u8 = null;
+ var flag: u32 = 0;
if (i < er.data.len and er.data[i] != '\n') {
- _ = scanNumber(er.data, &i) orelse return null;
+ flag = scanNumber(er.data, &i) orelse return null;
const n = scanNumber(er.data, &i) orelse return null;
if (i >= er.data.len or er.data[i] != ' ' or er.data.len - (i + 1) < n) return null;
text = er.data[i + 1 ..][0..n];
@@ -502,7 +629,7 @@ const EventReader = struct {
}
if (i >= er.data.len or er.data[i] != '\n') return null;
er.i = i + 1;
- return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .text = text };
+ return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .flag = flag, .text = text };
}
};
@@ -530,7 +657,9 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event),
}
const n = if (r.action.onTag()) tag.len else body.len;
- if (r.q0 > r.q1 or r.q1 > n) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
+ if (r.q0 > r.q1) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
+ // Well formed, but not a range of this text: say which.
+ if (r.q1 > n) return tree.failText(req.tag, E.INVAL, if (r.action.onTag()) "range past end of tag" else "range past end of body");
}
if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
}
@@ -548,9 +677,65 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
// been (acme's xfideventwrite, xfid.c:842-844): a REPL bound for
// it takes it. One that came from a write to exec (`F`) was
// never a click, and runs as the command it was.
- .body_exec => _ = exec.executeFrom(p, live, text, r.origin != 'F'),
- .tag_exec => _ = exec.execute(p, live, text),
- .body_look, .tag_look => look.lookAt(p, live, text),
+ // The chorded exec a reader was told of runs with its argument
+ // (mouse.reportGesture), when it is that range written back.
+ .body_exec, .tag_exec => {
+ const pf = &pane.fs;
+ // The two records that follow a chorded exec (its argument
+ // and where it came from), written back after it, are its:
+ // consumed, never run as commands of their own.
+ if (pf.chord_follow > 0 and r.q0 == 0 and r.q1 == 0 and r.flag == 0) {
+ pf.chord_follow -= 1;
+ // The argument a command written back alone waited for.
+ if (pf.chord_pending) |command| {
+ pf.chord_pending = null;
+ defer p.gpa.free(command);
+ const line = exec.withArg(p, command, r.text orelse "");
+ if (pf.chord_pending_tag) _ = exec.execute(p, live, line) else _ = exec.executeFrom(p, live, line, true);
+ }
+ continue;
+ }
+ var arg = if (pf.chord_arg) |a| (if (pf.chord_at.q0 == r.q0 and pf.chord_at.q1 == r.q1) a else null) else null;
+ if (r.flag & flag_chorded != 0) {
+ // Its argument is the next record when it came in the
+ // same write; the rest that follow are consumed.
+ pf.chord_follow = 2;
+ const save = run.i;
+ if (run.next()) |following| if (following.q0 == 0 and following.q1 == 0 and following.flag == 0) {
+ if (following.text) |t| arg = p.scratch.allocator().dupe(u8, t) catch arg;
+ pf.chord_follow = 1;
+ } else {
+ run.i = save;
+ };
+ // Alone, with no argument kept: it waits for its own.
+ if (arg == null) {
+ if (pf.chord_pending) |old| p.gpa.free(old);
+ pf.chord_pending = p.gpa.dupe(u8, text) catch null;
+ pf.chord_pending_tag = r.action == .tag_exec;
+ continue;
+ }
+ }
+ if (r.action == .tag_exec)
+ _ = exec.execute(p, live, exec.withArg(p, text, arg))
+ else
+ _ = exec.executeFrom(p, live, exec.withArg(p, text, arg), r.origin != 'F');
+ if (arg != null) if (p.paneBySerial(serial)) |still| {
+ const spf = &p.panes[still].?.fs;
+ if (spf.chord_arg) |a| p.gpa.free(a);
+ spf.chord_arg = null;
+ };
+ },
+ // What it found is what the look file reads back next, as a
+ // look written there would leave (ctl.run).
+ .body_look, .tag_look => {
+ p.look_missed = false;
+ look.lookAt(p, live, text);
+ p.fs.results_len = 0;
+ if (!p.look_missed) if (p.panes[p.active]) |found| {
+ p.fs.results[0] = found.serial;
+ p.fs.results_len = 1;
+ };
+ },
else => unreachable,
}
}
@@ -726,7 +911,7 @@ test "a pane deleted while its event file is open leaves no suppression behind"
try testing.expectEqual(@as(u16, 0), p.fs.listeners);
// The reader's next read hears what acme says of a window gone under it.
const shut = call(p, .{ .tag = 19, .op = .read, .node = event, .handle = a.reply.handle, .size = 64 });
- try testing.expectEqual(E.IO, shut.errno());
+ try testing.expectEqual(E.NOENT, shut.errno());
try testing.expectEqualStrings(tree.e_shut_down, shut.reply.ename);
_ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle });
@@ -767,6 +952,12 @@ test "writing an event record back performs the action it names" {
try testing.expectEqual(E.INVAL, wr(p, event, bad).errno());
try testing.expectEqual(@as(usize, 0), pane.msg_len);
}
+ // A look written back sets what look reads back.
+ _ = wr(p, event, "FL0 3\n");
+ var want_serial: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want_serial, "{d}\n", .{serial}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes);
+ // Well formed but past the text: said so, not "bad event syntax".
+ try testing.expectEqualStrings("range past end of body", wr(p, event, "MX0 999\n").reply.ename);
_ = call(p, .{ .tag = 23, .op = .open, .node = event });
p.fs.origin = 'K';
@@ -848,10 +1039,10 @@ test "the log records whether or not anyone reads, and an open that follows wait
try testing.expectEqual(Status.again, rdf.next(p, log, fh, frozen).reply.status);
// A shell's `read` asks for a few bytes at a time: the record comes in
- // pieces rather than failing. Every repeat is its own line.
+ // pieces rather than failing. A repeat the follower waits on, having
+ // read the first, is its own line.
const said_by = p.panes[0].?.serial;
p.setMessage(0, "same failure");
- p.setMessage(0, "same failure");
var pieced: [64]u8 = undefined;
var got: usize = 0;
while (got == 0 or pieced[got - 1] != '\n') {
@@ -860,7 +1051,8 @@ test "the log records whether or not anyone reads, and an open that follows wait
got += piece.len;
}
try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure\n", .{said_by}), pieced[0..got]);
- try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure\n", .{said_by}), rdf.next(p, log, fh, frozen).bytes);
+ p.setMessage(0, "same failure");
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure (x2)\n", .{said_by}), rdf.next(p, log, fh, frozen).bytes);
// A follower the ring outran hears how much it missed, then carries on.
var filler: [200]u8 = @splat('x');
@@ -911,19 +1103,20 @@ test "a refused or failed write is an err record in the log, saying which file a
// A write that succeeds says nothing.
_ = wr(p, Node.of(serial, .addr), "#0");
try testing.expectEqual(Status.again, call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).reply.status);
- // A follower that has read an err waits on its repeat: a line of its own.
+ // A follower that has read an err waits on its repeat: a line of its
+ // own, with the running count.
_ = wr(p, Node.of(serial, .addr), "/nowhere/");
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen));
_ = wr(p, Node.of(serial, .addr), "/nowhere/");
- try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen));
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp (x2)\n", .{serial}), next.record(p, f, frozen));
_ = call(p, .{ .tag = 6, .op = .release, .node = log, .handle = f });
- // With the follower gone, repeats are the newest record counted: the
- // one it read, and three more.
+ // With the follower gone, repeats are the newest record counted on:
+ // its two, and three more.
for (0..3) |_| _ = wr(p, Node.of(serial, .addr), "/elsewhere/");
const g = call(p, .{ .tag = 7, .op = .open, .node = log }).reply.handle;
const text = call(p, .{ .tag = 8, .op = .read, .node = log, .handle = g, .size = 1 << 16 }).bytes;
- try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "regexp\nerr {d} addr: no match for regexp (x4)\n", .{serial})));
+ try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "regexp\nerr {d} addr: no match for regexp (x5)\n", .{serial})));
_ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = g });
}
@@ -1020,3 +1213,46 @@ test "a click in the body reports the offsets of the word it takes, so the recor
_ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h });
}
}
+
+test "follow new skips what the log held and waits for what comes after" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ p.setMessage(0, "before the open");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = log, .handle = h, .data = "follow new\n" }).reply.status);
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = log, .handle = h, .size = 4096 }).reply.status);
+ p.setMessage(0, "after it");
+ try testing.expect(std.mem.endsWith(u8, call(p, .{ .tag = 4, .op = .read, .node = log, .handle = h, .size = 4096 }).bytes, " after it\n"));
+ _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h });
+}
+
+test "a long msg record is cut between words at its cap, with an ellipsis" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const long = "word " ** 60; // 300 bytes
+ _ = wr(p, Node.of(serialOf(p), .ctl), "Msg " ++ long ++ "\n");
+ 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;
+ const at = std.mem.lastIndexOf(u8, text, "msg ").?;
+ const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
+ try testing.expect(std.mem.endsWith(u8, record, " word…\n"));
+ try testing.expect(record.len <= "msg 1 ".len + pardes.Messages.LoggedMessage.cap + "…\n".len);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "a long err record is cut between words, with an ellipsis" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n");
+ 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;
+ const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?;
+ const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
+ try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index c594c7a3..d8329ccc 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -41,6 +41,9 @@ pub const State = struct {
/// rather than act at the address before it, which acme would do.
addr_failed: bool = false,
limit: ?Range = null,
+ /// The limit a truncation cleared, kept until the write that follows it
+ /// on that open (a shell's `>`): a write that fails puts it back.
+ limit_truncated: ?Range = null,
/// Opens of `event`, which hold the pane scripted.
readers: u16 = 0,
/// One of `readers` reads `event`; a second reading open is refused.
@@ -74,10 +77,23 @@ pub const State = struct {
/// Installed during this update; /log hears about it once the update ends
/// and the pane has its file, name and place.
unannounced: bool = false,
+ /// A chorded exec's argument an event reader was told of (flag 8), kept
+ /// for the record's write-back at `chord_at`, which runs with it.
+ chord_arg: ?[]u8 = null,
+ chord_at: Range = .{},
+ /// A chorded record written back: how many of its two follow-up records
+ /// (argument, location) are still to be consumed rather than run.
+ chord_follow: u8 = 0,
+ /// A chorded record written back alone with no argument kept: the
+ /// command, run when its argument record comes.
+ chord_pending: ?[]u8 = null,
+ chord_pending_tag: bool = false,
pub const Range = struct { q0: u32 = 0, q1: u32 = 0 };
pub fn deinit(pf: *State, gpa: std.mem.Allocator) void {
+ if (pf.chord_arg) |a| gpa.free(a);
+ if (pf.chord_pending) |c| gpa.free(c);
pf.events.deinit(gpa);
pf.pty_out.deinit(gpa);
pf.tag_snap.deinit(gpa);
@@ -128,11 +144,12 @@ pub fn clip(n: usize) u32 {
pub fn dotOf(pane: *Pane) State.Range {
const text = bodyOf(pane);
- const head = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
+ // In runes, as every address is (modal.runeStart).
+ const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) };
- const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
+ const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
var hi = @max(head, anchor);
- if (hi < text.len) hi = modal.nextGrapheme(text, hi);
+ if (hi < text.len) hi = modal.nextRune(text, hi);
return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) };
}
@@ -140,9 +157,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void {
const text = bodyOf(pane);
const q0 = @min(@as(usize, r.q0), text.len);
const q1 = @max(q0, @min(@as(usize, r.q1), text.len));
- const a = modal.positionAt(text, q0);
+ const a = modal.runePositionAt(text, q0);
pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
- const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0);
+ const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0);
pane.body.cur_row = @intCast(h.row);
pane.body.cur_col = @intCast(h.col);
pane.body.cur_pinned = true;
@@ -246,10 +263,11 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
const d = dotOf(pane);
break :sel @max(d.q0, d.q1) - d.q0;
},
- .addr, .dot => range_len,
+ .addr => if (pf.addr_failed) 0 else range_len,
+ .dot => range_len,
.limit => if (pf.limit == null) 0 else range_len,
.dirty, .mark, .scroll => 2,
- .look, .exec => ctl.resultsLen(p),
+ .look, .exec, .tagexec => ctl.resultsLen(p),
// What the next read would answer, which a client can see without
// parking on it.
.event => if (pf.events.peek()) |record| record.len else 0,
@@ -304,11 +322,12 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
break :tag tree.stagedReply(p, req);
},
.ctl => ctl.readPane(p, req, pane),
- .addr => addr: {
- if (pf.addr_failed) break :addr tree.failText(req.tag, E.INVAL, e_addr_failed);
+ // A failed address reads empty, as an unset limit does: there is
+ // none. data and xdata are what refuse.
+ .addr => readRange(p, req, if (pf.addr_failed) null else addr: {
clampAddr(pf, bodyOf(pane).len);
- break :addr readRange(p, req, pf.addr);
- },
+ break :addr pf.addr;
+ }),
.dot => readRange(p, req, dotOf(pane)),
.limit => readRange(p, req, pf.limit),
.data => readData(req, id, pane, pf, false),
@@ -317,7 +336,7 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
.dirty => readFlag(p, req, dirtyOf(pane)),
.mark => readFlag(p, req, !pf.nomark),
.scroll => readFlag(p, req, !pf.noscroll),
- .look, .exec => ctl.readResults(p, req),
+ .look, .exec, .tagexec => ctl.readResults(p, req),
.event => events.readQueue(p, req, &pf.events),
.pty_status => pty.readStatus(p, req, id, pane),
.pty_data => pty.readData(p, req, pf),
@@ -369,7 +388,7 @@ fn readData(req: Req, id: usize, pane: *Pane, pf: *State, stop_at_end: bool) Rep
const q0: usize = pf.addr.q0;
const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len;
var end = @min(hi, q0 +| req.size);
- end = @max(q0, modal.graphemeStart(text, end));
+ end = @max(q0, modal.runeStart(text, end));
pf.addr.q0 = clip(end);
if (!stop_at_end) pf.addr.q1 = clip(end);
if (pane.file == null) return .{ .tag = req.tag };
@@ -409,7 +428,9 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
.data, .xdata => writeData(p, req, pane),
.sel => writeSel(p, req, pane),
.dirty, .mark, .scroll => writeFlag(p, req, pane, file),
- .look, .exec => ctl.command(p, req, pane.serial, file == .exec),
+ .look, .exec => ctl.command(p, req, pane.serial, file == .exec, false),
+ // A click in its tag: never an event reader's to hear (cols.writeExec).
+ .tagexec => ctl.command(p, req, pane.serial, true, true),
.event => events.writeEvent(p, req, id),
.errors => if (appendErrors(p, id, req.data)) |took|
.{ .tag = req.tag, .written = @intCast(took) }
@@ -522,8 +543,13 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply {
};
const range = r orelse {
if (file == .addr) pf.addr_failed = true;
+ if (file == .limit) if (pf.limit_truncated) |kept| {
+ pf.limit = kept;
+ pf.limit_truncated = null;
+ };
return tree.failText(req.tag, E.INVAL, a.err);
};
+ if (file == .limit) pf.limit_truncated = null;
switch (file) {
.addr => {
pf.addr = range;
@@ -565,10 +591,18 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply {
/// Writing `name` renames the buffer: a relative name resolves against the
/// pane's directory, as the tag's name field does.
+/// acme's refusal of a name with a blank or a control character in it
+/// (editors/acme/xfid.c:650-652). A blank inside a name is taken here, as
+/// pardes names files with spaces; one at either end is refused, not
+/// quietly cut off, so the name a script wrote is the name it gets.
+const e_name_char = "bad character in file name";
+
fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- const name = std.mem.trim(u8, req.data, " \t\r\n");
+ // One line: its newline ends it, as `echo` writes it.
+ const name = if (std.mem.endsWith(u8, req.data, "\n")) req.data[0 .. req.data.len - 1] else req.data;
if (name.len == 0) return Reply.fail(req.tag, E.INVAL);
- for (name) |c| if (c < ' ') return Reply.fail(req.tag, E.INVAL);
+ for (name) |c| if (c < ' ') return tree.failText(req.tag, E.INVAL, e_name_char);
+ if (name[0] == ' ' or name[name.len - 1] == ' ') return tree.failText(req.tag, E.INVAL, e_name_char);
if (fileOf(pane) == null) return tree.failText(req.tag, E.PERM, if (pane.isTerminal())
"rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
else
@@ -638,6 +672,12 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize {
return null;
};
exec.placeNew(p, id, id, free, .errors);
+ // No column had room for it (it closes at the step's end): the text is
+ // logged instead, a msg a line, so it is not lost and nothing fails.
+ if (p.unplaced.isSet(free)) {
+ var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n');
+ while (lines.next()) |line| events.noteMessage(p, pane.serial, line[0..@min(line.len, pardes.Messages.LoggedMessage.cap)]);
+ }
return text.len;
}
@@ -683,7 +723,10 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
// is evaluated from where the last one left off, as with `>>`.
// `0` (or `,`) is how to start over. See State.addr.
.addr => {},
- .limit => pf.limit = null,
+ .limit => {
+ pf.limit_truncated = pf.limit;
+ pf.limit = null;
+ },
.dot => if (fileOf(pane) != null) setDot(pane, .{}),
else => {},
}
@@ -858,7 +901,7 @@ test "data and xdata read from addr, move it, and write through it" {
try testing.expectEqual(@as(u32, 4), p.panes[0].?.fs.addr.q0);
}
-test "data never splits a grapheme, in either direction" {
+test "data never splits a rune, in either direction" {
const gpa = testing.allocator;
const p = try withFile(gpa, "\u{00e9}x\n");
defer p.deinit();
@@ -1060,6 +1103,9 @@ test "name reads the file name and writing it promotes a scratch without touchin
try testing.expectEqualStrings("existing target\n", target);
for ([_][]const u8{ "", "\n", "bad\x01name\n" }) |bad|
try testing.expectEqual(E.INVAL, wr(p, name, bad).errno());
+ // A blank at either end is refused as acme refuses one, not cut off.
+ for ([_][]const u8{ "trailing.zig \n", " leading.zig\n", "tab\t.zig\n" }) |bad|
+ try testing.expectEqualStrings("bad character in file name", wr(p, name, bad).reply.ename);
try testing.expectEqualStrings(path, pane.file.?.path);
try testing.expectEqual(Status.ok, wr(p, name, "two words.zig\n").reply.status);
const spaced = try std.fmt.bufPrint(&path_buffer, "{s}/two words.zig", .{directory});
@@ -1108,11 +1154,15 @@ test "relative names use the file directory and can name a new target" {
try testing.expect(dirtyOf(pane));
}
-test "relative names follow inherited scratch and virtual directories" {
+test "relative names follow a pane/new scratch's session directory and virtual directories" {
const p = try withFile(testing.allocator, "source body\n");
defer p.deinit();
const source = p.panes[0].?;
- try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/project/src/source.zig\n").reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/elsewhere/source.zig\n").reply.status);
+ // pane/new's scratch is the session's, not the active pane's.
+ const session = "/project/src";
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = session.len;
const serial = try th.newPane(p);
const scratch = p.panes[p.paneBySerial(serial).?].?;
const name = Node.of(serial, .name);
@@ -1205,3 +1255,59 @@ test "setattr truncation empties the body and answers fresh attributes" {
const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) });
try testing.expectEqual(@as(u64, 9), noop.reply.attr.size);
}
+
+test "addresses are in runes: a combining mark and a lone \\r are places of their own" {
+ const gpa = testing.allocator;
+ // e, U+0301 (two bytes, 1-2), x, \r, \n
+ const p = try withFile(gpa, "e\u{0301}x\r\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const addr_node = Node.of(serial, .addr);
+ const dot = Node.of(serial, .dot);
+ const Case = struct { expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .expr = "#1,#3", .q0 = 1, .q1 = 3 }, // the mark alone
+ .{ .expr = "#2", .q0 = 1, .q1 = 1 }, // inside it: back to its start
+ .{ .expr = "#4,#5", .q0 = 4, .q1 = 5 }, // the \r alone
+ .{ .expr = "1:2", .q0 = 1, .q1 = 1 }, // line:col the same
+ .{ .expr = "1:3", .q0 = 1, .q1 = 1 },
+ .{ .expr = "0/\xCC/", .q0 = 1, .q1 = 3 }, // a match inside a rune covers it
+ }) |c| {
+ try testing.expectEqual(Status.ok, wr(p, addr_node, c.expr).reply.status);
+ try testing.expectEqual(c.q0, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(c.q1, p.panes[0].?.fs.addr.q1);
+ }
+ // Copied to dot, it reads back the same.
+ _ = wr(p, addr_node, "#1,#3");
+ try testing.expectEqual(Status.ok, wr(p, dot, " 1 3 ").reply.status);
+ p.sync();
+ try testing.expectEqualStrings(" 1 3 ", rd(p, dot, 0, 100).bytes);
+ _ = wr(p, addr_node, "#4,#5");
+ try testing.expectEqualStrings("\r", rd(p, Node.of(serial, .xdata), 0, 100).bytes);
+}
+
+test "a limit write that fails keeps the limit a truncation cleared before it" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one two\n");
+ defer p.deinit();
+ const limit = Node.of(serialOf(p), .limit);
+ _ = wr(p, limit, "#0,#3");
+ _ = call(p, .{ .tag = 1, .op = .setattr, .node = limit, .truncate = true });
+ try testing.expectEqual(Status.err, wr(p, limit, "/nomatch/").reply.status);
+ try testing.expectEqualStrings(" 0 3 ", rd(p, limit, 0, 100).bytes);
+ // A truncation with no write after it clears it.
+ _ = call(p, .{ .tag = 2, .op = .setattr, .node = limit, .truncate = true });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = limit });
+ try testing.expectEqualStrings("", rd(p, limit, 0, 100).bytes);
+}
+
+test "a pane's tagexec runs a word as a click in its tag, and reads back what it touched" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Msg from the tag\n").reply.status);
+ try testing.expect(th.logHas(p, "from the tag"));
+ var want: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, Node.of(serial, .tagexec), 0, 64).bytes);
+}
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 393516ce..4009af49 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -25,7 +25,25 @@ fn dimension(word: []const u8) ?u16 {
if (word.len == 0 or word.len > 5) return null;
for (word) |c| if (c < '0' or c > '9') return null;
const n = std.fmt.parseInt(u16, word, 10) catch return null;
- return if (n == 0) null else n;
+ return if (n == 0 or n > winsize_max) null else n;
+}
+
+/// The fewest rows a winsize write gives a terminal's pty.
+pub const pty_rows_min = 2;
+
+/// As the root ctl's `size` (4096 a side).
+const winsize_max = 4096;
+const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x1 to {d}x{d}", .{ winsize_max, winsize_max });
+
+/// A winsize whose numbers are numbers but not sizes: said as such.
+fn outOfRange(line: []const u8) bool {
+ var words = std.mem.tokenizeAny(u8, line, " \t");
+ if (!std.mem.eql(u8, words.next() orelse return false, "winsize")) return false;
+ var n: u8 = 0;
+ while (words.next()) |w| : (n += 1) {
+ _ = std.fmt.parseInt(u32, w, 10) catch return false;
+ }
+ return n == 2;
}
fn signalNamed(word: []const u8) ?pardes.PtySignal {
@@ -43,7 +61,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
- if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, e_bad_pty_ctl);
+ if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -56,7 +74,9 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
switch (v) {
.winsize => {
const cols = dimension(words.next() orelse return false) orelse return false;
- const rows = dimension(words.next() orelse return false) orelse return false;
+ // Two rows at least, as a pane is laid out: a one-row pty
+ // loses its prompt's input mark and reads busy for ever.
+ const rows = @max(pty_rows_min, dimension(words.next() orelse return false) orelse return false);
if (words.next() != null) return false;
if (!apply) return true;
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
@@ -72,7 +92,7 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
if (words.next() != null) return false;
if (pane.cwdSlice().len > pardes.effect_path_cap) return false;
if (!apply) return true;
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } });
+ p.emitSpawn(id, pane.serial, pane.cwdSlice());
},
}
return true;
@@ -189,7 +209,12 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
// marks: a nested shell's prompt (ssh, a shell with its own
// integration) looks like an empty prompt to ghostty but is not the
// shell this run knows.
- answer(p, slot, "busy", .{});
+ // Naming what runs, where the host can tell (Linux).
+ var name: [64]u8 = undefined;
+ if (p.hostForegroundName(id, &name)) |program|
+ answer(p, slot, "busy: {s} is running", .{program})
+ else
+ answer(p, slot, "busy", .{});
} else {
// Sent now if the shell is at its prompt, or at its first prompt
// when it has not drawn one yet (noteMarks).
@@ -433,7 +458,7 @@ test "a pane that is not a terminal has no pty/ at all" {
var buf: [32]th.Dirent = undefined;
const files = th.dirents(rdir(p, dir, 0).bytes, &buf);
- try testing.expectEqual(@as(usize, 17), files.len);
+ try testing.expectEqual(@as(usize, 18), files.len);
try testing.expect(th.nameAt(files, "pty") == null);
try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno());
@@ -454,7 +479,7 @@ test "a terminal pane's pty/ holds exactly ctl, status and data" {
var buf: [32]th.Dirent = undefined;
const files = th.dirents(rdir(p, dir, 0).bytes, &buf);
- try testing.expectEqual(@as(usize, 18), files.len);
+ try testing.expectEqual(@as(usize, 19), files.len);
try testing.expect(th.nameAt(files, "pty").?.dir);
const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf);
@@ -528,10 +553,7 @@ test "every pty/ctl verb, and every refusal" {
"winsize", // no arguments
"winsize 80", // one argument
"winsize 80 24 extra", // three
- "winsize 0 24", // zero is "unknown", never a width
- "winsize 80 0",
"winsize -1 24", // not a decimal
- "winsize 999999 24", // wider than a u16
"sig", // no name
"sig INT TERM", // two
"sig SIGINT",
@@ -553,6 +575,13 @@ test "every pty/ctl verb, and every refusal" {
try testing.expect(a.signal == null);
try testing.expect(!a.spawned);
}
+ // Numbers, but no size: said as out of range, the bound size's.
+ for ([_][]const u8{ "winsize 0 24", "winsize 80 0", "winsize 0 0", "winsize 4097 24", "winsize 999999 24" }) |bad| {
+ const a = wr(p, ctl, bad);
+ try testing.expectEqual(E.INVAL, a.errno());
+ try testing.expectEqualStrings(e_winsize_range, a.reply.ename);
+ try testing.expect(a.winsize == null);
+ }
const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
try testing.expectEqual(Status.ok, spaced.reply.status);
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index baf8c772..ed0e0352 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -66,7 +66,10 @@ pub const e_bad_event = "bad event syntax";
/// A second open reading a file whose reads consume: rio's word for it
/// (windows/rio/xfid.c:25), which 9ns turns into EBUSY.
pub const e_in_use = "file in use";
-pub const e_shut_down = "window shut down";
+/// A read held on a pane's event or pty/data that the pane closed under.
+/// acme says "window shut down" (xfid.c:1005), which 9ns reads as EIO;
+/// these words read as ENOENT, what every other file of a gone pane gives.
+pub const e_shut_down = "no such pane: its window shut";
/// Tremove closes a pane; nothing else in the tree is created or destroyed by
/// the protocol, and wstat stays a truncation. Making a pane is an open of
@@ -82,21 +85,21 @@ pub const features: cloud9.fs.Features = .{ .remove = true, .create = true };
/// the mutation of its own step, so the layout and the surface are whole
/// under it -- and a step out there is never waiting on a write of its own,
/// so what parks here is never the syscall's own request.
-pub fn needsQuiet(req: Req) bool {
+pub fn needsQuiet(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
- .release => req.remove,
+ .release => req.remove or holdsLines(p, req),
else => false,
};
}
/// `needsQuiet`, plus the open that makes a pane: what costs a frame and a
/// wake of the editor.
-pub fn changesPane(req: Req) bool {
+pub fn changesPane(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
.open => req.node == @intFromEnum(TopFile.new),
- .release => req.remove,
+ .release => req.remove or holdsLines(p, req),
.lookup, .getattr, .read, .readdir => false,
};
}
@@ -125,7 +128,20 @@ pub const Open = struct {
pty_data,
/// An open that may write a pane's ctl, and so hold its lock.
ctl,
+ /// An open of a file that takes lines of commands (look, exec, a
+ /// ctl, tagexec): its `pending` and `results`.
+ lines,
} = .{ .snapshot = null },
+ /// What a write to a file of command lines left unfinished: a last line
+ /// with no newline yet, or an Edit block still open. A write through a
+ /// mount is cut at the 9P message size, so one `echo` of many lines may
+ /// come as several writes, cut anywhere; the rest waits for the open's
+ /// next write, or its release, and never runs as a fragment.
+ pending: std.ArrayList(u8) = .empty,
+ /// The panes this open's last write touched, which a read of look or
+ /// exec on it answers, as /net/tcp/clone answers on the open that
+ /// asked; a fresh open holds the session's last, from before it opened.
+ results: Results = .{},
/// A read that found nothing yet (`.again`), kept to be answered when
/// this open's file has something, the way factotum keeps its log's
/// waiting reads and answers them on append (security/auth/factotum/
@@ -142,6 +158,16 @@ pub const Open = struct {
pub const Replay = struct { off: u64, bytes: []u8 };
+ pub const Results = struct {
+ list: [pardes.MAX_PANES]u32 = undefined,
+ len: u8 = 0,
+ /// Written on: reads are a stream from the write on, whatever
+ /// offset they come at, since a shell's `exec 3<>look` shares one
+ /// offset between the write and the read (as pty/run does).
+ wrote: bool = false,
+ read: usize = 0,
+ };
+
/// `asker` is the connection the read came on, which only the listener
/// knows.
pub const Held = struct { asker: *anyopaque, req: Req, ticket: cloud9.fs.Ticket };
@@ -154,8 +180,9 @@ pub const Open = struct {
gpa.free(run.output);
gpa.free(run.line);
},
- .event, .pty_data, .ctl => {},
+ .event, .pty_data, .ctl, .lines => {},
}
+ o.pending.deinit(gpa);
if (o.replay) |r| gpa.free(r.bytes);
o.* = .{};
}
@@ -191,6 +218,7 @@ pub const TopFile = enum(u5) {
layout,
tag,
col,
+ tagexec,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -199,7 +227,7 @@ pub const TopFile = enum(u5) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
.root, .pane, .col => 0o755,
- .look, .exec, .log, .focus, .ctl, .tag => 0o666,
+ .look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666,
.README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444,
};
}
@@ -231,6 +259,9 @@ pub const PaneFile = enum(u5) {
event,
look,
exec,
+ /// A word run as a click in the pane's tag, as /tagexec is in the
+ /// workspace's and col/<n>/exec in a column's.
+ tagexec,
pty,
pty_ctl,
pty_status,
@@ -468,10 +499,14 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
.dir = c.file == .dir,
.mode = switch (c.file) {
.dir => 0o755,
- .ctl, .exec => 0o222,
- .tag => 0o666,
+ .ctl => 0o222,
+ .tag, .exec => 0o666,
+ },
+ .size = switch (c.file) {
+ .tag => cols.headerText(p, c.serial).?.len + 1,
+ .exec => ctl.resultsLen(p),
+ else => 0,
},
- .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 else 0,
.mtime = p.fs.started,
.append = c.file == .tag,
};
@@ -513,7 +548,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.index => pane.indexLen(p),
.README => fs.help.len,
.status => ctl.statusLen(p),
- .look, .exec => ctl.resultsLen(p),
+ .look, .exec, .tagexec => ctl.resultsLen(p),
.log => events.logLen(&p.fs.log),
.listeners => listeners: {
var buf: [screen.listeners_cap]u8 = undefined;
@@ -618,7 +653,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
.root => {
// The guide, then what describes the session, then the
// files that act, then the directories.
- inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.pane, TopFile.col }) |t| {
+ inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| {
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
}
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os");
@@ -680,16 +715,26 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// pane it made, so reading it twice answers the same one and
// closing it leaves the pane alone.
.new => {
- const slot = p.freeSlot() orelse return failText(req.tag, E.NFILE, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no pane slots");
+ const slot = p.freeSlot() orelse return failText(req.tag, E.NOSPC, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no space for a pane");
+ p.fs.no_pane_slot = false;
+ // Refused, the open's err is the one record of it (the
+ // one rule): no msg besides.
+ p.fs.capturing = true;
p.newScratchFor(p.active, .fs);
+ p.fs.capturing = false;
+ p.fs.failure_len = 0;
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
+ // No room in its column: it closes at the step's end.
+ if (p.unplaced.isSet(slot)) return failText(req.tag, E.NOSPC, p.fs.ename[0..p.fs.no_pane_slot_len]);
return .{ .tag = req.tag, .handle = made.serial };
},
.screen => .{ .snapshot = null },
.log => .{ .log = .{} },
- else => return .{ .tag = req.tag },
+ else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
},
- .col => |c| return if (layout.columnBySerial(p, c.serial) != null) .{ .tag = req.tag } else Reply.fail(req.tag, E.NOENT),
+ .col => |c| if (layout.columnBySerial(p, c.serial) == null)
+ return Reply.fail(req.tag, E.NOENT)
+ else if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
.pane => |t| what: {
const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
@@ -706,7 +751,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
1, 2 => .ctl, // OWRITE, ORDWR
else => return .{ .tag = req.tag },
},
- else => return .{ .tag = req.tag },
+ else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
};
},
};
@@ -715,6 +760,10 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
} else return Reply.fail(req.tag, E.NFILE);
const o = &p.fs.opens[i];
o.* = .{ .node = req.node, .what = what };
+ if (resultsFile(target)) {
+ @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]);
+ o.results.len = p.fs.results_len;
+ }
switch (o.what) {
// A screen freezes at open; a terminal body at its first read.
.snapshot => |*bytes| if (pane_of == null) {
@@ -734,7 +783,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
pn.fs.event_reader = pn.fs.event_reader or reader;
},
.pty_data => pane_of.?.fs.pty_reader = true,
- .run, .ctl => {},
+ .run, .ctl, .lines => {},
}
return .{ .tag = req.tag, .handle = @intCast(i + 1) };
}
@@ -771,9 +820,21 @@ fn releaseHandle(p: *Pardes, req: Req) void {
p.panes[id].?.fs.tag_rewrite = false;
p.panes[id].?.fs.tag_held_newline = false;
};
+ // `: > limit` clears it for good once its open goes.
+ if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| {
+ p.panes[id].?.fs.limit_truncated = null;
+ };
if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial);
if (t == .top and t.top == .tag) cols.released(p, null);
const o = openOf(p, req) orelse return;
+ // The last line an open wrote with no newline after it runs now.
+ if (o.pending.items.len > 0) {
+ const rest = p.scratch.allocator().dupe(u8, o.pending.items) catch "";
+ o.pending.clearRetainingCapacity();
+ const w: Req = .{ .tag = req.tag, .op = .write, .node = req.node, .handle = req.handle, .data = rest };
+ const reply = writeNow(p, w, t);
+ if (reply.status == .err) events.noteError(p, w, reply);
+ }
const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null;
if (of) |pn| switch (o.what) {
.event => |reader| {
@@ -795,7 +856,7 @@ fn releaseHandle(p: *Pardes, req: Req) void {
.ctl => if (pn.fs.lock == req.handle) {
pn.fs.lock = null;
},
- .snapshot, .log => {},
+ .snapshot, .log, .lines => {},
};
o.deinit(p.gpa);
}
@@ -861,7 +922,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
break :index stagedReply(p, req);
},
.status => ctl.readStatus(p, req),
- .look, .exec => ctl.readResults(p, req),
+ .look, .exec, .tagexec => ctl.readResults(p, req),
.log => events.readLog(p, req),
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
@@ -892,25 +953,92 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
// An event or pty/data read the pane closed under: acme's
// answer to the same (editors/acme/xfid.c:1005).
if (openOf(p, req)) |o| if (o.what == .event or o.what == .pty_data)
- return failText(req.tag, E.IO, e_shut_down);
+ return failText(req.tag, E.NOENT, e_shut_down);
return Reply.fail(req.tag, E.NOENT);
};
const pn = p.panes[id].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
return pane.read(p, req, id, pn, t.file);
},
- .col => |c| return if (c.file == .tag) cols.read(p, req, c.serial) else Reply.fail(req.tag, E.PERM),
+ .col => |c| return switch (c.file) {
+ .tag => cols.read(p, req, c.serial),
+ .exec => ctl.readResults(p, req),
+ else => Reply.fail(req.tag, E.PERM),
+ },
}
}
+/// The files whose writes are lines of commands, run as each line is whole.
+fn linesFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec,
+ .col => |c| c.file == .ctl or c.file == .exec,
+ .pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec,
+ };
+}
+
+/// The ones of those a read answers with the panes a write touched.
+fn resultsFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .look or f == .exec or f == .tagexec,
+ .col => |c| c.file == .exec,
+ .pane => |t| t.file == .look or t.file == .exec or t.file == .tagexec,
+ };
+}
+
+/// An open that keeps lines or results: any that may write one of those
+/// files, and any that may read a look or exec.
+fn linesOpen(target: Target, req: Req) bool {
+ if (!linesFile(target)) return false;
+ return req.omode & 3 != 0 or resultsFile(target);
+}
+
+/// A release that will run a line its open held back.
+fn holdsLines(p: *Pardes, req: Req) bool {
+ const o = openOf(p, req) orelse return false;
+ return o.pending.items.len > 0;
+}
+
+/// A line held back longer than this is refused rather than kept growing.
+const pending_cap = 1 << 20;
+
fn write(p: *Pardes, req: Req, target: Target) Reply {
+ const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target);
+ if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target);
+ // A clicked line holds no control character: refused at once, not held
+ // to fail unseen at the close.
+ if (resultsFile(target)) for (req.data) |c| if (c < ' ' and c != '\t' and c != '\n' and c != '\r') return Reply.fail(req.tag, E.INVAL);
+ o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM);
+ const end = ctl.completeEnd(p, o.pending.items);
+ if (end == 0) {
+ if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+ o.pending.clearRetainingCapacity();
+ return failText(req.tag, E.INVAL, "a line or Edit block over 1 MiB");
+ }
+ const whole = p.scratch.allocator().dupe(u8, o.pending.items[0..end]) catch return Reply.fail(req.tag, E.NOMEM);
+ const rest = o.pending.items.len - end;
+ std.mem.copyForwards(u8, o.pending.items[0..rest], o.pending.items[end..]);
+ o.pending.shrinkRetainingCapacity(rest);
+ var w = req;
+ w.data = whole;
+ var reply = writeNow(p, w, target);
+ if (resultsFile(target)) {
+ @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]);
+ o.results = .{ .list = o.results.list, .len = p.fs.results_len, .wrote = true };
+ }
+ if (reply.status == .ok) reply.written = @intCast(req.data.len);
+ return reply;
+}
+
+fn writeNow(p: *Pardes, req: Req, target: Target) Reply {
switch (target) {
.top => |f| return switch (f) {
- .look, .exec => ctl.command(p, req, null, f == .exec),
+ .look, .exec => ctl.command(p, req, null, f == .exec, false),
.log => events.writeLog(p, req),
.focus => ctl.writeFocus(p, req),
.ctl => ctl.writeRoot(p, req),
.tag => cols.write(p, req, null),
+ .tagexec => cols.writeExec(p, req, null),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
@@ -1069,13 +1197,13 @@ test "readdir lists the root and a pane directory without creating anything" {
@memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes);
const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf);
try testing.expect(top.len >= 10);
- for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| {
+ for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| {
try testing.expectEqualStrings(name, top[i].name);
- try testing.expectEqual(i >= 13, top[i].dir);
+ try testing.expectEqual(i >= 14, top[i].dir);
}
- try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node);
- try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node);
- try testing.expectEqual(fs.os_root, top[15].node);
+ try testing.expectEqual(@intFromEnum(TopFile.pane), top[14].node);
+ try testing.expectEqual(@intFromEnum(TopFile.col), top[15].node);
+ try testing.expectEqual(fs.os_root, top[16].node);
var idbuf: [16]u8 = undefined;
try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null);
try testing.expect(th.nameAt(top, "self") == null);
@@ -1114,10 +1242,10 @@ test "readdir lists the root and a pane directory without creating anything" {
const dir = rdir(p, Node.of(serial, .dir), 0);
const files = th.dirents(dir.bytes, &buf);
- try testing.expectEqual(@as(usize, 17), files.len);
+ try testing.expectEqual(@as(usize, 18), files.len);
for ([_][]const u8{
"name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata",
- "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec",
+ "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", "tagexec",
}, 0..) |name, i|
try testing.expectEqualStrings(name, files[i].name);
try testing.expect(th.nameAt(files, ".") == null);
@@ -1203,6 +1331,10 @@ test "opening /pane/new makes a pane and removing one closes it" {
const id = p.paneBySerial(serial).?;
try testing.expectEqualStrings("", p.panes[id].?.file.?.content);
+ // Its directory is the session's, not that of /test.txt, the pane
+ // that happened to have the keyboard.
+ var named: [4096]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&named, "{s}/+New", .{p.sessionDir()}), p.panes[id].?.file.?.path);
_ = wr(p, Node.of(serial, .body), "hi");
// A second open is a second pane, and releasing a clone fid leaves the
@@ -1310,14 +1442,19 @@ test "editor paths resolve to the same nodes the wire serves" {
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
+ // Tall enough that the column holds every pane at its tag and two rows,
+ // and wide enough that each tag, the session directory's +New, is one.
+ p.update(.{ .resize = .{ .cols = 400, .rows = pardes.MAX_PANES * 4 } });
const new = @intFromEnum(TopFile.new);
while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
var want: [64]u8 = undefined;
- const said = try std.fmt.bufPrint(&want, "no pane slots: {d} max", .{pardes.MAX_PANES});
+ const said = try std.fmt.bufPrint(&want, "no space for a pane: {d} max", .{pardes.MAX_PANES});
const refused = call(p, .{ .tag = 2, .op = .open, .node = new });
- try testing.expectEqual(E.NFILE, refused.errno());
+ try testing.expectEqual(E.NOSPC, refused.errno());
try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said));
try testing.expect(th.logHas(p, said));
+ // Its err alone says it: no msg besides (the one rule).
+ try testing.expect(!th.logHas(p, "New: no space"));
// A look that would open a pane fails its write, and says the same.
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
@@ -1335,3 +1472,113 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e
const made = th.wr(p, ctl_node, "New\n");
try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null);
}
+
+test "a command line cut across writes runs once whole, and the last runs at release; an Edit block waits for its end" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec = @intFromEnum(TopFile.exec);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
+ try testing.expect(h != 0);
+ for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece|
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status);
+ try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n"));
+ try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true });
+ try testing.expect(th.logHas(p, " tail\n"));
+ // An Edit block's text lines come in later writes on the same open.
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const c = call(p, .{ .tag = 4, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ for ([_][]const u8{ "Edit ,a\n", "one\ntw", "o\n", ".\n" }) |piece|
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = piece }).reply.status);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content);
+}
+
+test "look and exec read back what their own open's write touched; a fresh open reads the session's last" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec = @intFromEnum(TopFile.exec);
+ const first = p.panes[0].?.serial;
+ const mine = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 2 }).reply.handle;
+ const theirs = call(p, .{ .tag = 2, .op = .open, .node = exec, .omode = 2 }).reply.handle;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = exec, .handle = mine, .data = "Tty\n" });
+ const made = p.panes[p.active].?.serial;
+ try testing.expect(made != first);
+ _ = call(p, .{ .tag = 4, .op = .write, .node = exec, .handle = theirs, .data = "Msg hi\n" });
+ var want: [16]u8 = undefined;
+ // Read at the offset the write left, as a shell's `exec 3<>` does.
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), call(p, .{ .tag = 5, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes);
+ try testing.expectEqualStrings("", call(p, .{ .tag = 6, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes);
+ const said = p.panes[p.active].?.serial;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 7, .op = .read, .node = exec, .handle = theirs, .size = 64 }).bytes);
+ const fresh = call(p, .{ .tag = 8, .op = .open, .node = exec }).reply.handle;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 9, .op = .read, .node = exec, .handle = fresh, .size = 64 }).bytes);
+ for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
+}
+
+test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const new = @intFromEnum(TopFile.new);
+ var made: usize = 1;
+ const refused = while (made < pardes.MAX_PANES) : (made += 1) {
+ const a = call(p, .{ .tag = 1, .op = .open, .node = new });
+ if (a.reply.status == .err) break a;
+ } else return error.NeverRefused;
+ try testing.expectEqual(E.NOSPC, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "no space for a pane in that column") != null);
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(made, count); // the refused one is gone
+ for (0..p.ncol) |c| for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (p.col_n[c] > 1 and !p.panes[k].?.collapsed)
+ try testing.expect(p.rects[k].h >= p.panes[k].?.tag_rows + pardes.layout.min_body_rows);
+ };
+}
+
+test "an Edit block still open when its open closes fails there with an err and changes nothing" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,x/b/{\ni/[/\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expect(th.logHas(p, "unmatched"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "an a, c or i text still open when its open closes fails there too and changes nothing" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a\nhalf of a block\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expect(th.logHas(p, "not ended by a . line"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "an a, c or i text cut short through exec is an err in the log, not a msg" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const exec = Node.of(serialOf(p), .exec);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = c, .data = "Edit $a\nhalf\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = c, .opened = true });
+ var want: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: Edit: a, c or i text not ended", .{serialOf(p)})));
+ try testing.expect(!th.logHas(p, "msg 1 Edit: a, c or i"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "a pane made from pane/new goes last in its column, even when the last pane has no room to halve" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for (0..3) |_| {
+ const made = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }).reply.handle;
+ p.sync();
+ try testing.expectEqual(made, p.panes[p.col_panes[0][p.col_n[0] - 1]].?.serial);
+ }
+}