diff options
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/addr.zig | 47 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 170 | ||||
| -rw-r--r-- | src/ninep/events.zig | 110 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 34 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 36 |
5 files changed, 372 insertions, 25 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index cc7ca632..0537c17a 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -14,8 +14,11 @@ 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_regexp = "bad regular expression"; -pub const e_slow = "regular expression search took too long"; +/// Not "took too long": 9ns reads errors by their words, and that would be +/// ENAMETOOLONG. +pub const e_slow = "regular expression search gave up, backtracking past its step budget"; pub const e_syntax = "bad address syntax"; +pub const e_order = "addresses out of order"; pub const Addr = struct { text: []const u8, @@ -93,7 +96,9 @@ pub const Addr = struct { size = .line; }, '/', '?' => { - const back = c == '?'; + // `-/re/` searches back as `?re?` does (acme's address() + // takes the direction a `-` set, addr.c). + const back = c == '?' or dir == '-'; r = a.regexp(r, a.pattern(c), back) orelse return null; dir = 0; size = .line; @@ -243,6 +248,44 @@ const Node = tree.Node; const E = tree.E; const Status = tree.Status; +test "an address is evaluated from the current one, as acme's are" { + const p = try th.withFile(testing.allocator, "a foo b foo c foo\n"); + defer p.deinit(); + const addr = Node.of(th.serialOf(p), .addr); + const Step = struct { expr: []const u8, q0: u32, q1: u32 }; + _ = th.wr(p, addr, "#0"); + for ([_]Step{ + // each search starts where the last address ended + .{ .expr = "/foo/", .q0 = 2, .q1 = 5 }, + .{ .expr = "/foo/", .q0 = 8, .q1 = 11 }, + // `.` is that address, and `-` before a pattern searches back + .{ .expr = ".", .q0 = 8, .q1 = 11 }, + .{ .expr = "-/foo/", .q0 = 2, .q1 = 5 }, + .{ .expr = "./foo/", .q0 = 8, .q1 = 11 }, + .{ .expr = "+/foo/", .q0 = 14, .q1 = 17 }, + // a number then a pattern: the search starts at the number + .{ .expr = "#9/foo/", .q0 = 14, .q1 = 17 }, + }) |c| { + try testing.expectEqual(Status.ok, th.wr(p, addr, 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); + } + // A range whose end comes before its start is sam's error. + try testing.expectEqualStrings(e_order, th.wr(p, addr, "#10,#5").reply.ename); + + // `;` evaluates its right side from its left (acme's addr.c:170-171, + // `ar = r`), `,` from the address the whole started at. + const q = try th.withFile(testing.allocator, "b a b\n"); + defer q.deinit(); + const qaddr = Node.of(th.serialOf(q), .addr); + _ = th.wr(q, qaddr, "#0"); + try testing.expectEqual(Status.ok, th.wr(q, qaddr, "/a/;/b/").reply.status); + try testing.expectEqual(@as(u32, 2), q.panes[0].?.fs.addr.q0); + try testing.expectEqual(@as(u32, 5), q.panes[0].?.fs.addr.q1); + _ = th.wr(q, qaddr, "#0"); + try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename); +} + 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(); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index cd327d03..a64521e7 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -53,8 +53,26 @@ fn noteResult(p: *Pardes, serial: u32) void { /// the pane it acted on (even one it closed). fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) 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; + 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); + return noteResult(p, context); + } const before = Serials.snapshot(p); - if (exec) _ = pardes.exec.execute(p, id, text) else look.lookAt(p, id, text); + // An exec answers the pane it went to: a command pane it reused, or + // the terminal it typed into. + const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: { + look.lookAt(p, id, text); + break :blk null; + }; 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) { @@ -62,6 +80,7 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { created = true; }; if (created) return; + if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial); noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial); } @@ -144,7 +163,7 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { try w.writeByte('\''); } -pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len; +pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48; /// acme's winctlprint: serial, tag length, body length, a reserved zero, the /// dirty flag, then the width in cells, the font and the tab width. Then @@ -167,6 +186,10 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { // While a column's or the workspace's tag has the keyboard, no pane does. const current = p.panes[p.active] == pane and !p.header_focus; w.print(" {d:>11} {s} ", .{ config.tab_width, if (current) "current" else "notcurrent" }) catch {}; + // A terminal bound as a REPL ends with its id, `python-a`. + var idbuf: [48]u8 = undefined; + const id = exec_line.replId(&idbuf, pane); + if (id.len > 0) w.print("{s} ", .{id}) catch {}; return w.buffered(); } @@ -177,7 +200,7 @@ const Builtin = builtins.registry.Builtin(); /// Refuses a control message, quoting it the way Plan 9's cmderror does /// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`. fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { - const room = p.fs.ename.len - why.len - 3; + const room = p.fs.ename.len -| (why.len + 3); const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why; return tree.failText(req.tag, E.INVAL, text); } @@ -201,7 +224,12 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? const takes = switch (setting.action) { .theme => for (pardes.themes) |t| { if (std.mem.eql(u8, t.name, arg)) break true; - } else false, + } else { + // `refuse` quotes the line after it, in what is left of 128. + var near: [40]u8 = undefined; + var why: [104]u8 = undefined; + return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; ThemeSel lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line); + }, .font => config.Runtime.FontSpec.parse(arg) != null, else => probe: { var probe = p.settings; @@ -220,7 +248,18 @@ fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { p.fs.refused = false; p.fs.failure_len = 0; defer p.fs.no_prompt = false; + // What a ctl write touched reads back from /look and /exec as a click's + // does (`run`), so a script that made a pane with `Tty` finds it there. + const context = p.panes[id].?.serial; + const before = Serials.snapshot(p); _ = exec_line.executeBuiltinLine(p, id, line); + 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) { + noteResult(p, serial); + created = true; + }; + if (!created) noteResult(p, context); if (p.fs.refused) return refuse(p, req, e_prompt, line); if (p.fs.failure_len == 0) return null; const refusal = refuse(p, req, p.fs.failure[0..p.fs.failure_len], line); @@ -292,6 +331,7 @@ pub fn readRoot(p: *Pardes, req: Req) Reply { /// runs, as the root's look and exec click there; all are checked first. pub fn writeRoot(p: *Pardes, req: Req) Reply { for ([2]bool{ false, true }) |apply| { + if (apply) p.fs.results_len = 0; var it = std.mem.splitScalar(u8, req.data, '\n'); while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); @@ -361,7 +401,7 @@ pub fn writeFocus(p: *Pardes, req: Req) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -const e_no_pane = "no such window"; +const e_no_pane = "no such pane"; pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply { var buf: [pane_text_cap]u8 = undefined; @@ -399,6 +439,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { // Checked whole before anything applies, so a write that must wait for // the lock has done nothing yet when it goes again. for ([2]bool{ false, true }) |apply| { + if (apply) p.fs.results_len = 0; var held = !other and pane.fs.lock != null; var it = std.mem.splitScalar(u8, req.data, '\n'); while (it.next()) |raw| { @@ -615,7 +656,9 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi try testing.expectEqualStrings("not a session control message \"Del\"", wr(p, root_ctl, "Del").reply.ename); try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename); try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename); - try testing.expectEqualStrings("bad value in control message \"Theme no-such-theme\"", wr(p, root_ctl, "Theme no-such-theme").reply.ename); + const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename; + try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n"); + try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\"")); try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename); try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename); // A bare required word fails the check, so the line before never runs. @@ -625,7 +668,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi // its line; the lines before it have taken effect, as in acme. const failed = wr(p, root_ctl, "Verbose on\nMount x\nVerbose off"); try testing.expectEqual(E.IO, failed.errno()); - try testing.expectEqualStrings("Mount name dial: MissingArgument \"Mount x\"", failed.reply.ename); + try testing.expectEqualStrings("Mount name dial: missing argument \"Mount x\"", failed.reply.ename); try testing.expect(p.settings.verbose); try testing.expect(!p.fs.no_prompt); try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off").reply.status); @@ -860,3 +903,116 @@ test "exec Save writes the named file through the host" { try testing.expect(!pane_files.dirtyOf(pane)); try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); } + +test "a pane's own look and exec go to the event reader holding it, which writes them back to act" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg routed\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg routed\n").reply.status); + // Not done: the reader has it, as it would a middle click. + 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); + // 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); + // 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); + _ = 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); + try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "direct") != null); +} + +test "look and exec read back what the last command touched, a ctl write's too" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + var want: [32]u8 = undefined; + _ = wr(p, root_exec, "Msg first\n"); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, root_exec, 0, 64).bytes); + // A pane made through ctl is what both read next, not the Msg's pane. + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status); + const made = p.panes[p.active].?.serial; + try testing.expect(made != serial); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes); + // 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); +} + +test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" { + // 9ns (cloud9's 9ns/src/nine.zig) maps an error to an errno by its + // words, and EIO when none of them match: these words must not. + const addressing = @import("addr.zig"); + const einval = [_][]const u8{ "control message", "invalid", "bad " }; + const other = [_][]const u8{ "interrupt", "shut down", "not exist", "not found", "no such", "exists", "not empty", "not a dir", "is a dir", "permission", "denied", "read-only", "read only", "no space", "not allowed", "not permitted", "cannot", "fid", "busy", "in use", "too long", "not supported", "unsupported" }; + const Case = struct { text: []const u8, malformed: bool }; + for ([_]Case{ + .{ .text = addressing.e_syntax, .malformed = true }, + .{ .text = addressing.e_regexp, .malformed = true }, + .{ .text = e_prompt, .malformed = true }, + .{ .text = addressing.e_no_match, .malformed = false }, + .{ .text = addressing.e_range, .malformed = false }, + .{ .text = addressing.e_order, .malformed = false }, + .{ .text = addressing.e_slow, .malformed = false }, + .{ .text = "/tmp/x.txt: Modified (Exit again to discard)", .malformed = false }, + }) |c| { + const says_einval = for (einval) |w| { + if (std.ascii.findIgnoreCase(c.text, w) != null) break true; + } else false; + try testing.expectEqual(c.malformed, says_einval); + for (other) |w| try testing.expect(std.ascii.findIgnoreCase(c.text, w) == null); + } +} + +test "Joincol with no column to the right and Theme with no such theme say so" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const root_ctl = @intFromEnum(tree.TopFile.ctl); + const joined = wr(p, root_ctl, "Joincol\n"); + try testing.expectEqual(tree.Status.err, joined.reply.status); + try testing.expectStringStartsWith(joined.reply.ename, "Joincol: no column to the right"); + const themed = wr(p, root_ctl, "Theme drak\n"); + try testing.expectEqual(E.INVAL, themed.errno()); + try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: "); + try testing.expect(std.mem.indexOf(u8, themed.reply.ename, " dark") != null or std.mem.indexOf(u8, themed.reply.ename, ": dark") != null); + try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\"")); + // A click on the word says it on the message row. + _ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n"); + const pane = p.panes[p.active].?; + try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null); +} + +test "a look that fills a column's stillborn scratch is answered with the scratch already gone" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "file\n" }); + var dir: [4096]u8 = undefined; + const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; + var line: [4200]u8 = undefined; + // Newcol makes a column of one empty +New; a file looked from it + // joins that column and takes the scratch's place. + try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status); + const scratch = p.panes[p.active].?.serial; + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(scratch, .look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); + try testing.expect(p.paneBySerial(scratch) == null); + var want: [32]u8 = undefined; + const gone = try std.fmt.bufPrint(&want, "{d} ", .{scratch}); + var rows = std.mem.splitScalar(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, '\n'); + while (rows.next()) |row| try testing.expect(!std.mem.startsWith(u8, row, gone)); +} diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 69dc271d..ffdea95b 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -7,6 +7,7 @@ const look = @import("../look.zig"); const cloud9 = @import("cloud9"); const tree = @import("tree.zig"); const pane_files = @import("pane.zig"); +const dump = @import("../dump.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; @@ -155,6 +156,13 @@ pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void { /// kernel mapped the reply to (`Invalid argument`), and here is the reason. /// The log is the one place for it, as acme's `errors` file takes text and /// answers nothing: a per-pane readable error file would be a second. +/// `dump <path>` when a Dump is written, `restore <path>` first in a +/// Restore's replacement. +pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void { + var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined; + pushLog(p, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ what, path }) catch return); +} + 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; @@ -460,7 +468,7 @@ pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { p.fs.news = true; } -const EventRecord = struct { action: Action, q0: u32, q1: u32 }; +const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, text: ?[]const u8 = null }; const EventReader = struct { data: []const u8, @@ -470,15 +478,27 @@ const EventReader = struct { if (er.i >= er.data.len) return null; var i = er.i; if (i + 2 > er.data.len) return null; + const origin = er.data[i]; i += 1; const action = Action.fromChar(er.data[i]) orelse return null; i += 1; const q0 = scanNumber(er.data, &i) orelse return null; const q1 = scanNumber(er.data, &i) orelse return null; while (i < er.data.len and er.data[i] == ' ') i += 1; + // acme takes only `origin action q0 q1`; the record whole, as read, + // 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; + if (i < er.data.len and er.data[i] != '\n') { + _ = 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]; + i += 1 + n; + } if (i >= er.data.len or er.data[i] != '\n') return null; er.i = i + 1; - return .{ .action = action, .q0 = q0, .q1 = q1 }; + return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .text = text }; } }; @@ -517,9 +537,15 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { const whole = if (r.action.onTag()) pane_files.tagOf(p, pane) else pane_files.bodyOf(pane); const lo = @min(@as(usize, r.q0), whole.len); const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); - const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; + const said = if (r.q0 == r.q1) r.text orelse whole[lo..hi] else whole[lo..hi]; + const text = p.scratch.allocator().dupe(u8, said) catch continue; switch (r.action) { - .body_exec, .tag_exec => _ = exec.execute(p, live, text), + // A body's click written back is done as the click would have + // 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), else => unreachable, } @@ -664,7 +690,8 @@ test "a pane deleted while its event file is open leaves no suppression behind" const b = call(p, .{ .tag = 19, .op = .open, .node = event, .omode = 1 }); try testing.expectEqual(@as(u16, 2), p.fs.listeners); - _ = wr(p, Node.of(serial, .exec), "Del\n"); + // Through ctl: a line written to its exec would go to the reader. + _ = wr(p, Node.of(serial, .ctl), "Del\n"); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(@as(u16, 0), p.fs.listeners); // The reader's next read hears what acme says of a window gone under it. @@ -850,7 +877,7 @@ test "a refused or failed write is an err record in the log, saying which file a _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus"); try testing.expectEqualStrings("err - ctl: unknown control message \"Bogus\"\n", next.record(p, f, frozen)); _ = wr(p, @intFromEnum(tree.TopFile.focus), "99999"); - try testing.expectEqualStrings("err - focus: no such window\n", next.record(p, f, frozen)); + try testing.expectEqualStrings("err - focus: no such pane\n", next.record(p, f, frozen)); // 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); @@ -870,6 +897,21 @@ test "a refused or failed write is an err record in the log, saying which file a _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = g }); } +test "a Dump written and a Restore made are in the log, with their files" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + p.setLastDump("/tmp/pardes.dump.zon"); + try dump.dumpState(p); + const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); + defer restored.deinit(); + for ([_]*Pardes{ p, restored }, [_][]const u8{ "dump", "restore" }) |core, what| { + const log = try freezeLog(core); + defer core.gpa.free(log.bytes); + var want: [64]u8 = undefined; + try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what}))); + } +} + test "opens of the log share the open records, and a closed one frees its record" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); @@ -885,3 +927,59 @@ test "opens of the log share the open records, and a closed one frees its record for (handles) |h| _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h }); for (p.fs.opens) |o| try testing.expect(o.node == 0); } + +test "a click in the body reports the offsets of the word it takes, so the record can be written back" { + const config = @import("../config.zig"); + const gpa = testing.allocator; + const text = "alpha qty beta\n qty\n\tqty\ndef f(qty): return qty.x\n" ++ "w" ** 100 ++ " qty\n"; + const p = try withFile(gpa, text); + defer p.deinit(); + // A scratch made over 9P, clicked while another pane has the keyboard. + const scratch = try th.newPane(p); + _ = wr(p, Node.of(scratch, .body), text); + for ([_]u32{ serialOf(p), scratch }) |serial| { + const id = p.paneBySerial(serial).?; + p.active = p.paneBySerial(if (serial == scratch) serialOf(p) else scratch).?; + const event = Node.of(serial, .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[id]; + const x0 = rect.x + config.GUTTER + config.PREFIX_W; + const y0 = if (p.settings.tag_bottom) rect.y else rect.y + p.panes[id].?.tag_rows; + const tab: u16 = config.tab_width; + const Click = struct { x: u16, y: u16, look: []const u8, exec: []const u8, scroll: i32 = 0 }; + // Where the wrapped line's `qty` landed, as drawn. + const surface = try p.render(frame.allocator()); + var wrap_col: u16 = 0; + while (wrap_col < rect.w and !std.mem.eql(u8, surface.at(x0 + wrap_col, y0 + 5).grapheme(), "q")) wrap_col += 1; + for ([_]Click{ + .{ .x = x0 + 7, .y = y0, .look = "ML6 9 0 3 qty\n", .exec = "MX6 9 0 3 qty\n" }, + .{ .x = x0 + 5, .y = y0 + 1, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n" }, + .{ .x = x0 + tab + 1, .y = y0 + 2, .look = "ML24 27 0 3 qty\n", .exec = "MX24 27 0 3 qty\n" }, + .{ .x = x0 + 7, .y = y0 + 3, .look = "ML34 37 0 3 qty\n", .exec = "MX34 37 0 3 qty\n" }, + // Scrolled a line, the first row shows the second. + .{ .x = x0 + 5, .y = y0, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n", .scroll = 1 }, + // Wrapped: the word on the continuation row. + .{ .x = x0 + wrap_col, .y = y0 + 5, .look = "ML154 157 0 3 qty\n", .exec = "MX154 157 0 3 qty\n" }, + }) |c| for ([_]pardes.Mouse.Button{ config.look_button, config.exec_button }, [_][]const u8{ c.look, c.exec }) |button, want| { + // The pointer rests on the word first, as a hand's does, and + // the hover preview comes up under it. + p.panes[id].?.scrollBy(c.scroll); + _ = try p.render(frame.allocator()); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = c.x, .row = c.y } }); + for (0..200) |_| p.update(.tick); + p.update(.{ .mouse = .{ .button = button, .kind = .press, .col = c.x, .row = c.y } }); + p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = c.x, .row = c.y } }); + try testing.expectEqualStrings(want, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); + p.panes[id].?.scrollBy(-c.scroll); + }; + // A click on nothing expands to nothing, and acme then says + // nothing (look.c:37, exec.c:141). + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = x0 + 60, .row = y0 + 1 } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = x0 + 60, .row = y0 + 1 } }); + try testing.expectEqual(Status.again, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).reply.status); + _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h }); + } +} diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 0a511118..d2b2a791 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -194,6 +194,20 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const return take; } +test "a terminal's body stats as long as it reads" { + const gpa = testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.isTerminal()); + p.update(.{ .output = .{ .pane = 0, .bytes = "$ ls\r\nalpha beta\r\n$ " } }); + const body = Node.of(pane.serial, .body); + const whole = rd(p, body, 0, 1 << 16); + try testing.expect(whole.bytes.len > 0); + try testing.expectEqual(@as(u64, whole.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = body }).reply.attr.size); +} + // ---- what stat reports ---- /// A range file's text: acme's addr format, two right-aligned offsets, so @@ -204,7 +218,9 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pane = p.panes[id] orelse return 0; const pf = &pane.fs; return switch (f) { - .body, .data, .xdata => bodyOf(pane).len, + // A terminal's body reads its screen and history, as text. + .body => if (pane.file == null and pane.isTerminal()) panes.Terminal.screenTextLen(pane) else bodyOf(pane).len, + .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, .name => nameOf(pane).len + 1, .ctl => ctl: { @@ -453,16 +469,22 @@ pub const e_addr_failed = "no address: the last one written to addr failed"; /// A range file takes an address expression, or that pair of offsets. One /// that does not evaluate says why: `bad address syntax`, `no match for -/// regexp`, `address out of range`, `bad regular expression`. +/// regexp`, `address out of range`, `bad regular expression`, `addresses +/// out of order`. fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply { const pf = &pane.fs; const text = bodyOf(pane); clampAddr(pf, text.len); const expr = std.mem.trimEnd(u8, req.data, "\n"); var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; - const r = if (pairOf(expr)) |pair| + var r = if (pairOf(expr)) |pair| State.Range{ .q0 = @min(pair.q0, clip(text.len)), .q1 = @min(pair.q1, clip(text.len)) } else if (a.address(pf.addr)) |found| (if (a.i < expr.len) null else found) else null; + // sam's check, which acme leaves out: `#100,#50` names no range. + if (r) |found| if (found.q0 > found.q1) { + a.err = addressing.e_order; + r = null; + }; const range = r orelse { if (file == .addr) pf.addr_failed = true; return tree.failText(req.tag, E.INVAL, a.err); @@ -485,7 +507,7 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply { fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { const word = std.mem.trim(u8, req.data, " \t\r\n"); - if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return Reply.fail(req.tag, E.INVAL); + if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return tree.failText(req.tag, E.INVAL, "takes 1 or 0"); const on = word[0] == '1'; const pf = &pane.fs; switch (file) { @@ -851,8 +873,10 @@ test "the flag files read and write the state acme kept behind ctl verbs" { for ([_]u64{ dirty, mark, scroll }) |node| { try testing.expectEqual(@as(u64, 2), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); - for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| + for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| { try testing.expectEqual(E.INVAL, wr(p, node, bad).errno()); + try testing.expectEqualStrings("takes 1 or 0", wr(p, node, bad).reply.ename); + } } try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index dcf9a8a7..3599b612 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -43,7 +43,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, tree.e_bad_ctl); + if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, e_bad_pty_ctl); } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; @@ -65,13 +65,13 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { const which = signalNamed(words.next() orelse return false) orelse return false; if (words.next() != null) return false; if (!apply) return true; - p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); + p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .serial = pane.serial, .sig = which } }); }, .exec => { if (words.next() != null) return false; if (pane.cwdSlice().len > pardes.effect_path_cap) return false; if (!apply) return true; - p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); + p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } }); }, } return true; @@ -92,7 +92,8 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { // Busy is what a run written now would be told: a command is running // or a line is typed at the prompt. Before the shell's first prompt a // run waits for it rather than answer busy, so that is not busy. - const marks = if (pane.terminal) |state| &state.stream.handler else null; + // A build with no terminal panes (the board) has no marks to read. + const marks = if (comptime !pardes.panes.Terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null; const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false))); const out = p.fs.stage(p.gpa); @@ -162,10 +163,14 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line); + if (comptime !pardes.panes.Terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line); const pf = &pane.fs; const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line); const marks = &state.stream.handler; - if (pf.unmarked) { + if (pane.command != null) { + // Its child is the command, and no shell is there to take a line. + answer(p, slot, "error {s}", .{if (pane.command_done) "command done; not a shell" else "a command runs here, not a shell"}); + } else if (pf.unmarked) { answer(p, slot, "error no prompt marks", .{}); } else if (waitingRun(p, pane) != null or (marks.prompts > 0 and (marks.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)))) @@ -335,6 +340,7 @@ fn waitingRun(p: *Pardes, pane: *Pane) ?*Run { } const e_bad_line = "bad command line"; +const e_bad_pty_ctl = tree.e_bad_ctl ++ "; takes winsize COLS ROWS, sig INT|TERM|HUP|QUIT|KILL, or exec"; pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply { if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; @@ -505,6 +511,7 @@ test "every pty/ctl verb, and every refusal" { }) |bad| { const a = wr(p, ctl, bad); try testing.expectEqual(E.INVAL, a.errno()); + try testing.expectEqualStrings(e_bad_pty_ctl, a.reply.ename); // names what it takes try testing.expect(a.winsize == null); try testing.expect(a.signal == null); try testing.expect(!a.spawned); @@ -743,6 +750,25 @@ test "run answers how its command ended once the shell is back at a prompt" { for (p.fs.opens) |o| try testing.expect(o.node == 0); } +test "run on a command pane says no shell is there" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .pty_run); + const pane = p.panes[0].?; + pane.command = try gpa.dupe(u8, "make"); + for ([_]struct { bool, []const u8 }{ + .{ false, "error a command runs here, not a shell\n" }, + .{ true, "error command done; not a shell\n" }, + }) |case| { + pane.command_done = case[0]; + const o = call(p, .{ .tag = 1, .op = .open, .node = node }); + _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = o.reply.handle, .data = "ls\n" }); + try testing.expectEqualStrings(case[1], call(p, .{ .tag = 3, .op = .read, .node = node, .handle = o.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = o.reply.handle }); + } +} + test "a run's answer says cut when its output's start is gone, and reads a bounded tail" { const gpa = testing.allocator; const p = try withTerm(gpa); |
