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