diff options
Diffstat (limited to 'src/ninep/ctl.zig')
| -rw-r--r-- | src/ninep/ctl.zig | 503 |
1 files changed, 503 insertions, 0 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig new file mode 100644 index 00000000..d61ebf75 --- /dev/null +++ b/src/ninep/ctl.zig @@ -0,0 +1,503 @@ +//! The two ctl files. `look TEXT` is a right click and `exec TEXT` a middle +//! click in the editor's own command language; a pane's ctl adds the addr +//! verbs. Reading /ctl answers the serials the last command made or touched. +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const panes = @import("../panes.zig"); +const config = @import("../config.zig"); +const fs = @import("../fs.zig"); +const tree = @import("tree.zig"); +const pane_files = @import("pane.zig"); + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const MAX_PANES = pardes.MAX_PANES; +const Req = tree.Req; +const Reply = tree.Reply; +const E = tree.E; + +const Verb = enum { + @"addr=dot", + clean, + cleartag, + dirty, + @"dot=addr", + get, + @"limit=addr", + mark, + nomark, + noscroll, + scroll, + show, +}; + +fn verbIs(line: []const u8, word: []const u8) bool { + if (!std.mem.startsWith(u8, line, word)) return false; + return line.len == word.len or line[word.len] == ' ' or line[word.len] == '\t'; +} + +/// The text after `look` or `exec`, or null when the line is not that verb. +/// Control characters other than tab are refused. +fn textAfter(line: []const u8, verb: []const u8) ?[]const u8 { + if (!verbIs(line, verb)) return null; + const text = std.mem.trim(u8, line[verb.len..], " \t"); + if (text.len == 0) return ""; + for (text) |c| if (c < ' ' and c != '\t') return ""; + return text; +} + +// ---- which panes a command made or touched ---- + +const Serials = struct { + list: [MAX_PANES]u32 = undefined, + len: usize = 0, + + fn snapshot(p: *Pardes) Serials { + var s: Serials = .{}; + for (p.panes) |slot| if (slot) |pane| { + s.list[s.len] = pane.serial; + s.len += 1; + }; + return s; + } + + fn has(s: *const Serials, serial: u32) bool { + for (s.list[0..s.len]) |known| if (known == serial) return true; + return false; + } +}; + +fn noteResult(p: *Pardes, serial: u32) void { + for (p.fs.results[0..p.fs.results_len]) |known| if (known == serial) return; + if (p.fs.results_len == p.fs.results.len) return; + p.fs.results[p.fs.results_len] = serial; + p.fs.results_len += 1; +} + +fn activeSerial(p: *Pardes) ?u32 { + const pane = p.panes[p.active] orelse return null; + return pane.serial; +} + +/// 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). +fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { + const context = p.panes[id].?.serial; + const before = Serials.snapshot(p); + if (exec) _ = p.execute(id, text) else p.lookAt(id, text); + const after = Serials.snapshot(p); + var created = false; + for (after.list[0..after.len]) |serial| if (!before.has(serial)) { + noteResult(p, serial); + created = true; + }; + if (created) return; + noteResult(p, if (exec) context else activeSerial(p) orelse return); +} + +fn beginResults(p: *Pardes) void { + p.fs.results_len = 0; + p.fs.results_valid = true; +} + +// ---- /ctl ---- + +pub fn writeRoot(p: *Pardes, req: Req) Reply { + for ([2]bool{ false, true }) |apply| { + if (apply) beginResults(p); + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + const exec = verbIs(line, "exec"); + const text = textAfter(line, if (exec) "exec" else "look") orelse return Reply.fail(req.tag, E.INVAL); + if (text.len == 0) return Reply.fail(req.tag, E.INVAL); + if (!apply) continue; + if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); + run(p, p.active, exec, text); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +pub fn rootText(p: *Pardes, buf: []u8) []const u8 { + var w = std.Io.Writer.fixed(buf); + if (p.fs.results_valid) { + for (p.fs.results[0..p.fs.results_len]) |serial| w.print("{d}\n", .{serial}) catch break; + return w.buffered(); + } + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + const pid: u32 = if (comptime pardes.hosted) @intCast(std.c.getpid()) else 0; + w.print("pid {d}\nversion {s}\npanes {d}\n", .{ pid, pardes.version, count }) catch {}; + return w.buffered(); +} + +pub const root_text_cap = @max(MAX_PANES * 11, 64 + pardes.version.len); + +pub fn readRoot(p: *Pardes, req: Req) Reply { + var buf: [root_text_cap]u8 = undefined; + const text = rootText(p, &buf); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); + return tree.stagedReply(p, req); +} + +// ---- /pane/<n>/ctl ---- + +fn fontName(p: *Pardes) []const u8 { + const name = p.settings.font.effective_name.get(); + return if (name.len == 0) "default" else name; +} + +fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { + const plain = s.len > 0 and for (s) |c| { + if (c <= ' ' or c == '\'') break false; + } else true; + if (plain) return w.writeAll(s); + try w.writeByte('\''); + for (s) |c| { + if (c == '\'') try w.writeByte('\''); + try w.writeByte(c); + } + try w.writeByte('\''); +} + +pub const pane_text_cap = 7 * 12 + 2 * 256 + 4; + +/// 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. +pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { + var w = std.Io.Writer.fixed(buf); + w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ + pane.serial, + pane_files.tagOf(p, pane).len, + pane_files.bodyOf(pane).len, + @as(u32, 0), + @intFromBool(pane_files.dirtyOf(pane)), + pane.cols, + }) catch return w.buffered(); + writeQuoted(&w, fontName(p)) catch return w.buffered(); + w.print(" {d:>11} ", .{config.tab_width}) catch {}; + return w.buffered(); +} + +pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply { + var buf: [pane_text_cap]u8 = undefined; + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, paneText(p, pane, &buf)) catch return Reply.fail(req.tag, E.NOMEM); + return tree.stagedReply(p, req); +} + +pub fn writePane(p: *Pardes, req: Req, serial: u32) Reply { + for ([2]bool{ false, true }) |apply| { + if (apply) beginResults(p); + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); + const errno = paneVerb(p, live, line, apply); + if (errno != 0) return Reply.fail(req.tag, errno); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn paneVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) u16 { + const pane = p.panes[id] orelse return E.INVAL; + const pf = &p.fs.panes[id]; + + for ([_]bool{ false, true }) |exec| if (textAfter(line, if (exec) "exec" else "look")) |text| { + if (text.len == 0) return E.INVAL; + if (apply) run(p, id, exec, text); + return 0; + }; + const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL; + if (!apply) return 0; + + switch (v) { + .@"addr=dot" => pf.addr = pane_files.dotOf(pane), + .@"dot=addr" => { + pane_files.clampAddr(pf, pane_files.bodyOf(pane).len); + pane_files.setDot(pane, pf.addr); + }, + .@"limit=addr" => { + pane_files.clampAddr(pf, pane_files.bodyOf(pane).len); + pf.limit = pf.addr; + }, + .clean => if (pane_files.fileOf(pane)) |f| { + f.saved_revision = f.revision; + }, + .dirty => if (pane_files.fileOf(pane)) |f| { + f.saved_revision = f.revision -% 1; + }, + .cleartag => { + pane.tag_tail_len = 0; + pane.tag_init = true; + }, + .get => if (pane_files.fileOf(pane)) |f| { + if (panes.Output.fileTraits(f.output).saves) { + if (fs.read(p, f.path)) |bytes| { + panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, bytes); + f.saved_revision = f.revision; + } else |err| return switch (err) { + error.FileNotFound => E.NOENT, + else => E.IO, + }; + } + }, + .mark => { + pf.nomark = false; + panes.File.pushUndo(p, pane); + }, + .nomark => pf.nomark = true, + .noscroll => pf.noscroll = true, + .scroll => pf.noscroll = false, + .show => pane_files.showOffset(pane, pane_files.dotOf(pane).q0), + } + return 0; +} + +// ---- tests ---- + +const testing = std.testing; +const th = @import("testing.zig"); +const call = th.call; +const rd = th.rd; +const wr = th.wr; +const withFile = th.withFile; +const serialOf = th.serialOf; +const Node = tree.Node; +const Status = tree.Status; +const root_ctl = @intFromEnum(tree.TopFile.ctl); + +test "pane ctl read is index's five fields plus width in cells, font and tab width" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + + const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); + try testing.expectEqual(Status.ok, a.reply.status); + var want: std.ArrayList(u8) = .empty; + defer want.deinit(gpa); + try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{ + pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, + }); + try testing.expectEqualStrings(want.items, a.bytes); + try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size); + + var quoted: [64]u8 = undefined; + var w = std.Io.Writer.fixed("ed); + try writeQuoted(&w, "DejaVu Sans Mono"); + try testing.expectEqualStrings("'DejaVu Sans Mono'", w.buffered()); + w = std.Io.Writer.fixed("ed); + try writeQuoted(&w, "it's"); + try testing.expectEqualStrings("'it''s'", w.buffered()); +} + +test "every pane ctl verb, and every refusal" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const pane = p.panes[0].?; + const pf = &p.fs.panes[0]; + + try testing.expectEqual(Status.ok, wr(p, ctl_node, "nomark\nnoscroll\ndirty\n").reply.status); + try testing.expect(pf.nomark and pf.noscroll and pane_files.dirtyOf(pane)); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "mark\nscroll\nclean\n").reply.status); + try testing.expect(!pf.nomark and !pf.noscroll and !pane_files.dirtyOf(pane)); + + _ = wr(p, ctl_node, "cleartag\n"); + try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + + _ = wr(p, Node.of(serial, .addr), "2"); + _ = wr(p, ctl_node, "limit=addr\n"); + try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); + _ = wr(p, ctl_node, "dot=addr\nshow\n"); + try testing.expectEqual(@as(i32, 1), pane.cur_row); + + for ([_][]const u8{ + "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", + "name x.txt", "put", "del", "delete", "Look x", "Exec Save", + }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl_node, bad).errno()); + + try testing.expect(!pane_files.dirtyOf(pane)); + try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nbogus\n").errno()); + try testing.expect(!pane_files.dirtyOf(pane)); +} + +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"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[0].?; + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Msg from ctl\n").reply.status); + try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]); + var expected: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{serial}), rd(p, root_ctl, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec New\n").reply.status); + const made = p.panes[p.active].?; + try testing.expect(made.serial != serial); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .ctl), "exec Del\n").reply.status); + try testing.expect(p.paneBySerial(made.serial) == null); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes); +} + +test "the root ctl reads editor status until a command runs and validates whole batches" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg root\n"); + defer p.deinit(); + const status = rd(p, root_ctl, 0, 4096); + try testing.expectEqual(Status.ok, status.reply.status); + var lines = std.mem.tokenizeScalar(u8, status.bytes, '\n'); + try testing.expect(std.mem.startsWith(u8, lines.next().?, "pid ")); + var expected: [64]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "version {s}", .{pardes.version}), lines.next().?); + try testing.expectEqualStrings("panes 1", lines.next().?); + try testing.expect(lines.next() == null); + const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl }); + try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size); + try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode); + + const serial = p.next_serial; + for ([_][]const u8{ "look", "exec", "look \t", "Look /virtual/index", "exec Msg a\x00b", "exec Msg x\nbogus\n", "name x", "put" }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, root_ctl, invalid).errno()); + try testing.expectEqual(serial, p.next_serial); + } + try testing.expectEqualStrings(status.bytes, rd(p, root_ctl, 0, 4096).bytes); + + const pane = p.panes[0].?; + try testing.expectEqual(Status.ok, wr(p, root_ctl, "exec Msg hello there\n").reply.status); + try testing.expectEqualStrings("hello there", pane.msg[0..pane.msg_len]); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_ctl, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, root_ctl, "look /virtual/index\nexec New\n").reply.status); + var made: [MAX_PANES]u32 = undefined; + var n: usize = 0; + for (p.panes) |slot| if (slot) |q| if (q.serial != pane.serial) { + made[n] = q.serial; + n += 1; + }; + try testing.expectEqual(@as(usize, 2), n); + const listed = rd(p, root_ctl, 0, 4096); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n{d}\n", .{ @min(made[0], made[1]), @max(made[0], made[1]) }), listed.bytes); + const opened = p.panes[p.paneBySerial(@min(made[0], made[1])).?].?; + try testing.expectEqualStrings("/virtual/index", opened.file.?.path); +} + +test "look opens spaced paths and locations without editing the source pane" { + if (!fs.platform_has_fs) return error.SkipZigTest; + const gpa = testing.allocator; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" }); + var path_buf: [4096]u8 = undefined; + const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)]; + const p = try withFile(gpa, "source stays intact\n"); + defer p.deinit(); + const source = p.panes[0].?; + const ctl_node = Node.of(source.serial, .ctl); + const revision = source.file.?.revision; + const undo_len = source.file.?.history.undo_len; + source.cur_col = 3; + var command: [4200]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, ctl_node, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status); + const opened = p.panes[p.active].?; + try testing.expect(opened != source); + try testing.expectEqualStrings(path, opened.file.?.path); + try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content); + try testing.expectEqual(@as(i32, 1), opened.cur_row); + try testing.expectEqual(@as(i32, 2), opened.cur_col); + try testing.expectEqualStrings("source stays intact\n", source.file.?.content); + try testing.expectEqual(revision, source.file.?.revision); + try testing.expectEqual(undo_len, source.file.?.history.undo_len); + try testing.expectEqual(@as(i32, 3), source.cur_col); + var expected: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes); + + p.active = 0; + try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status); + try testing.expect(p.panes[p.active].? == opened); + try testing.expectEqual(@as(i32, 2), opened.cur_row); + try testing.expectEqual(@as(i32, 1), opened.cur_col); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes); +} + +test "look validates the whole batch before opening virtual files" { + const gpa = testing.allocator; + const p = try withFile(gpa, "source\n"); + defer p.deinit(); + const source = p.panes[0].?; + const ctl_node = Node.of(source.serial, .ctl); + const serial = p.next_serial; + for ([_][]const u8{ + "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", + }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, ctl_node, invalid).errno()); + try testing.expectEqual(serial, p.next_serial); + try testing.expectEqual(@as(usize, 0), p.active); + try testing.expectEqualStrings("source\n", source.file.?.content); + } + try testing.expectEqual(Status.ok, wr(p, ctl_node, "look /n/self/index\n").reply.status); + const opened = p.panes[p.active].?; + try testing.expectEqualStrings("/virtual/index", opened.file.?.path); + try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); + try testing.expectEqualStrings("source\n", source.file.?.content); +} + +test "get reloads the pane from disk and reports a missing file without losing dirty contents" { + const gpa = testing.allocator; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + var path_buf: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory}); + + const p = try withFile(gpa, "in memory\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const pane = p.panes[0].?; + + var name: [std.fs.max_path_bytes + 8]u8 = undefined; + _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}\n", .{path})); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "get\n").reply.status); + 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); + + _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory})); + _ = wr(p, Node.of(serial, .body), "unsaved\n"); + try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\nexec Del\n").errno()); + try testing.expect(p.paneBySerial(serial) != null); + try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content); + try testing.expect(pane_files.dirtyOf(pane)); +} + +test "exec Save writes the named file through the host" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[0].?; + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status); + try testing.expect(pane_files.dirtyOf(pane)); + const saved = wr(p, Node.of(serial, .ctl), "exec Save\n"); + try testing.expectEqual(Status.ok, saved.reply.status); + try testing.expect(saved.saved); + p.perform(.{ .save_file = .{ .pane = 0 } }); + try testing.expect(!pane_files.dirtyOf(pane)); + try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); +} |
