diff options
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/addr.zig | 188 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 124 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 847 | ||||
| -rw-r--r-- | src/ninep/events.zig | 322 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 146 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 49 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 309 |
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); + } +} |
