summaryrefslogtreecommitdiff
path: root/src/edit_cmd.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/edit_cmd.zig')
-rw-r--r--src/edit_cmd.zig797
1 files changed, 797 insertions, 0 deletions
diff --git a/src/edit_cmd.zig b/src/edit_cmd.zig
new file mode 100644
index 00000000..83cc7e4f
--- /dev/null
+++ b/src/edit_cmd.zig
@@ -0,0 +1,797 @@
+//! acme's Edit in the editor (exec.c `edit`, edit.c editcmd): the panes an
+//! Edit sees and what it does to them. src/sam_edit.zig runs the commands
+//! over the open text panes; here they are handed over, and what came of
+//! them applied: each file's changes as one undo step, the dots, `w`'s
+//! writes, the names `e` and `f` gave, `D`'s closes, and what was printed,
+//! to +Errors.
+//!
+//! An Edit with `<`, `|` or `>` runs their commands off the loop, as the
+//! `|` key's filter does (selection_pipe.zig, Pipe.zig): the commands go out
+//! as one pipe request, each in its file's directory through the session's
+//! Shell, and the Edit is applied when they have all answered -- or not at
+//! all, when one fails or a file it changes was edited meanwhile. One Edit
+//! waits at a time; a 9P write that ran it is answered when it is done
+//! (9p_io, `fs.edit_hold`), the connection serving on meanwhile; a flush
+//! of that write stops the commands and changes nothing.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const sam = @import("sam_edit.zig");
+const panes = @import("panes.zig");
+const layout = @import("layout.zig");
+const look = @import("look.zig");
+const builtins = @import("builtins.zig");
+const filesystem = @import("fs.zig");
+const selection_pipe = @import("selection_pipe.zig");
+const Pipe = @import("Pipe.zig");
+const ninep_pane = @import("ninep/pane.zig");
+const Pardes = pardes.Pardes;
+
+/// An Edit whose commands are running: what it came to, and the request
+/// for them, all in `arena`.
+pub const Pending = struct {
+ id: u32 = 0,
+ arena: std.heap.ArenaAllocator,
+ /// The pane the Edit ran from, which hears how it went.
+ origin: usize,
+ origin_serial: u32,
+ res: sam.Result = undefined,
+ /// Each file's pane serial and text revision when the Edit read it.
+ serials: []u32 = &.{},
+ revisions: []u32 = &.{},
+ request: selection_pipe.Request = undefined,
+
+ pub fn deinit(pd: *Pending, gpa: std.mem.Allocator) void {
+ pd.arena.deinit();
+ gpa.destroy(pd);
+ }
+};
+
+/// What `w` took, written by the `edit_write` effect (pardes.zig perform).
+pub const Write = struct {
+ pane: u8,
+ serial: u32,
+ path: []u8,
+ bytes: []u8,
+ /// The pane comes clean if its text is still this revision.
+ clean: ?u32 = null,
+ /// A scratch written whole is that file from then on (exec.saveTo).
+ promote: bool = false,
+
+ pub fn deinit(w: *Write, gpa: std.mem.Allocator) void {
+ gpa.free(w.path);
+ gpa.free(w.bytes);
+ }
+};
+
+/// `B`, `e` and `r` through the editor.
+const World = struct {
+ p: *Pardes,
+ from: usize,
+ arena: std.mem.Allocator,
+
+ fn check(ctx: *anyopaque, path: []const u8, why: *sam.Why) bool {
+ const w: *World = @ptrCast(@alignCast(ctx));
+ for (w.p.panes) |slot| {
+ const pane = slot orelse continue;
+ const f = ninep_pane.fileOf(pane) orelse continue;
+ if (std.mem.eql(u8, f.path, path)) return true;
+ }
+ if (comptime !pardes.hosted) return true;
+ const local = filesystem.localPath(path) orelse return true;
+ if (@import("exec.zig").isDirectory(local)) return said(bool, why, "B: {s} is a directory", .{path}) orelse false;
+ var buf: [4096]u8 = undefined;
+ if (filesystem.resolveOs(local, &buf) == null) return said(bool, why, "B: {s}: no such file", .{path}) orelse false;
+ return true;
+ }
+
+ fn refuseGet(ctx: *anyopaque, id: usize, why: *sam.Why) bool {
+ const w: *World = @ptrCast(@alignCast(ctx));
+ const words = @import("ninep/ctl.zig").getRefused(w.p, w.p.panes[id] orelse return false, "e") orelse return false;
+ return said(bool, why, "{s}", .{words}) orelse true;
+ }
+
+ fn open(ctx: *anyopaque, path: []const u8, why: *sam.Why) ?sam.File {
+ const w: *World = @ptrCast(@alignCast(ctx));
+ const p = w.p;
+ if (comptime pardes.hosted) if (filesystem.localPath(path)) |local| if (@import("exec.zig").isDirectory(local))
+ return said(sam.File, why, "B: {s} is a directory", .{path});
+ // A look opens it, or finds it open, where a look would; the
+ // keyboard stays where the Edit ran.
+ const was = p.active;
+ look.lookAt(p, w.from, path);
+ if (p.panes[was] != null) p.active = was;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const f = ninep_pane.fileOf(pane) orelse continue;
+ if (!std.mem.eql(u8, f.path, path)) continue;
+ return fileOf(w.arena, pane, id) catch said(sam.File, why, "B: out of memory", .{});
+ }
+ return said(sam.File, why, "B: cannot open {s}", .{path});
+ }
+
+ fn read(ctx: *anyopaque, arena: std.mem.Allocator, path: []const u8, why: *sam.Why) ?[]const u8 {
+ const w: *World = @ptrCast(@alignCast(ctx));
+ if (comptime pardes.hosted) if (filesystem.localPath(path)) |local| if (@import("exec.zig").isDirectory(local))
+ return said([]const u8, why, "{s} is a directory", .{path});
+ const bytes = filesystem.read(w.p, path) catch |err| {
+ // In the words Get says them (builtins.Get).
+ var buf: [128]u8 = undefined;
+ const words = if (err == error.FileNotFound) "no such file" else pardes.Messages.errorWords(err, &buf);
+ return said([]const u8, why, "can't open {s}: {s}", .{ path, words });
+ };
+ defer w.p.gpa.free(bytes);
+ return arena.dupe(u8, bytes) catch return said([]const u8, why, "can't read {s}: out of memory", .{path});
+ }
+
+ fn said(comptime T: type, why: *sam.Why, comptime fmt: []const u8, args: anytype) ?T {
+ why.len = if (std.fmt.bufPrint(&why.buf, fmt, args)) |s| s.len else |_| 0;
+ return null;
+ }
+};
+
+/// What the Edit sees of a pane. Its name is a copy in `arena`: the pane
+/// may be gone by the time a running Edit's end says it. Its text is the
+/// pane's own, read only while the Edit runs its commands' parse.
+fn fileOf(arena: std.mem.Allocator, pane: *panes.Pane, id: usize) !sam.File {
+ const f = ninep_pane.fileOf(pane).?;
+ return .{
+ .name = try arena.dupe(u8, f.path),
+ .text = f.content,
+ .dot = ninep_pane.dotOf(pane),
+ .dirty = ninep_pane.dirtyOf(pane),
+ .scratch = f.output != null,
+ .id = id,
+ };
+}
+
+/// `Edit command` from pane `id`, a text pane.
+pub fn run(p: *Pardes, id: usize, command: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ if (ninep_pane.fileOf(pane) == null) return p.reportFailure(id, "Edit: only a text pane is edited");
+ const pd = p.gpa.create(Pending) catch |err| return p.reportError(id, "Edit", err);
+ pd.* = .{ .arena = .init(p.gpa), .origin = id, .origin_serial = pane.serial };
+ var keep = false;
+ defer if (!keep) pd.deinit(p.gpa);
+ const arena = pd.arena.allocator();
+
+ // Every text pane in the order they were made, as /index lists them,
+ // so X goes the same way whatever the layout; the Edit starts in its own.
+ var files: std.ArrayList(sam.File) = .empty;
+ for (p.panes, 0..) |slot, i| {
+ const q = slot orelse continue;
+ if (q.file == null) continue;
+ files.append(arena, fileOf(arena, q, i) catch |err| return p.reportError(id, "Edit", err)) catch |err| return p.reportError(id, "Edit", err);
+ }
+ std.mem.sort(sam.File, files.items, p, struct {
+ fn before(core: *Pardes, a: sam.File, b: sam.File) bool {
+ return core.panes[a.id].?.serial < core.panes[b.id].?.serial;
+ }
+ }.before);
+ const cur = for (files.items, 0..) |f, i| {
+ if (f.id == id) break i;
+ } else unreachable;
+
+ var world: World = .{ .p = p, .from = id, .arena = arena };
+ var why: sam.Why = .{};
+ var text: [260]u8 = undefined;
+ const res = sam.run(arena, files.items, cur, command, .{ .ctx = &world, .check = World.check, .open = World.open, .read = World.read, .refuseGet = World.refuseGet }, &why) catch |err| switch (err) {
+ error.Edit => return p.reportFailure(id, std.fmt.bufPrint(&text, "Edit: {s}", .{why.text()}) catch "Edit: failed"),
+ error.OutOfMemory => return p.reportError(id, "Edit", err),
+ };
+ // acme's Edit blocks the editor while its commands run; here the editor
+ // goes on, and so does any Edit that runs no command and leaves alone
+ // the files the running one will change. A second Edit with commands,
+ // or one that would change such a file, is refused, busy.
+ if (p.pipe.edit_run) |running| {
+ if (res.jobs.len > 0) return p.reportFailure(id, "Edit: busy: another Edit's commands are running; one Edit runs commands at a time");
+ for (res.files, 0..) |f, i| {
+ const touches = if (res.undo != 0) f.id == id else changes(f) or for (res.writes) |w| {
+ if (w.file == i) break true;
+ } else false;
+ if (!touches) continue;
+ const q = p.panes[f.id] orelse continue;
+ for (running.res.files, 0..) |g, j| if (g.id == f.id and running.serials[j] == q.serial and changes(g))
+ return p.reportFailure(id, std.fmt.bufPrint(&text, "Edit: busy: {s}: an Edit's commands are running on it", .{f.name}) catch "Edit: busy");
+ }
+ }
+ if (res.undo != 0) {
+ for (0..@abs(res.undo)) |_| if (res.undo > 0) panes.File.undo(p, pane) else panes.File.redo(p, pane);
+ return;
+ }
+ pd.res = res;
+ // What each file was, to know at the end that it still is.
+ pd.serials = arena.alloc(u32, res.files.len) catch |err| return p.reportError(id, "Edit", err);
+ pd.revisions = arena.alloc(u32, res.files.len) catch |err| return p.reportError(id, "Edit", err);
+ for (res.files, 0..) |f, i| {
+ const q = p.panes[f.id].?;
+ pd.serials[i] = q.serial;
+ pd.revisions[i] = q.file.?.revision;
+ }
+ if (res.jobs.len == 0) return commit(p, pd, &.{});
+
+ // The commands, as one pipe request: each with its input copied (the
+ // text may change while they run, and the Edit is then refused), in
+ // its file's directory.
+ const n = res.jobs.len;
+ const commands = arena.alloc([]const u8, n) catch |err| return p.reportError(id, "Edit", err);
+ const cwds = arena.alloc([]const u8, n) catch |err| return p.reportError(id, "Edit", err);
+ const inputs = arena.alloc(selection_pipe.Input, n) catch |err| return p.reportError(id, "Edit", err);
+ for (res.jobs, 0..) |job, i| {
+ const f = res.files[job.file];
+ commands[i] = job.command;
+ cwds[i] = arena.dupe(u8, Pardes.paneDir(p.panes[f.id].?)) catch |err| return p.reportError(id, "Edit", err);
+ inputs[i] = .{ .bytes = if (job.c == '<') "" else arena.dupe(u8, f.text[job.q0..job.q1]) catch |err| return p.reportError(id, "Edit", err) };
+ }
+ p.pipe.seq +%= 1;
+ pd.id = p.pipe.seq;
+ pd.request = .{
+ .id = pd.id,
+ .command = commands[0],
+ .cwd = cwds[0],
+ .inputs = inputs,
+ .commands = commands,
+ .cwds = cwds,
+ .shell = arena.dupe(u8, p.shellBin()) catch |err| return p.reportError(id, "Edit", err),
+ .token = selection_pipe.newToken(),
+ // What a pane's command is given: $PARDES_MOUNT, $PARDES_9P,
+ // $PARDES_PID, $winid as the Edit's pane.
+ .env = if (comptime pardes.hosted) @import("host_io.zig").commandEnv(arena, pane.serial, !p.opts.nested) catch &.{} else &.{},
+ };
+ keep = true;
+ p.pipe.edit_run = pd;
+ p.emit(.{ .pipe = .{ .id = pd.id } });
+ // A 9P write that ran it is held, and answered once it is done
+ // (9p_io, fs.edit_hold); the connection serves on meanwhile.
+ if (p.fs.serving) p.fs.edit_started = true;
+}
+
+/// What a pane whose Edit's commands are running refuses to do beside them:
+/// close, undo, redo, Get, Zerox (busyOn).
+pub const e_busy = "busy: an Edit's commands are running on it";
+
+/// Whether pane `id` (or a Zerox twin of it) is one the Edit whose commands
+/// are running will change or run a command on.
+pub fn busyOn(p: *Pardes, id: usize) bool {
+ const pd = p.pipe.edit_run orelse return false;
+ const pane = p.panes[id] orelse return false;
+ const twin = if (pane.file) |f| f.twin else 0;
+ for (pd.res.files, 0..) |f, i| {
+ const used = changes(f) or for (pd.res.jobs) |job| {
+ if (job.file == i) break true;
+ } else false;
+ if (!used) continue;
+ const q = p.panes[f.id] orelse continue;
+ if (q.serial != pd.serials[i]) continue;
+ if (f.id == id) return true;
+ if (twin != 0) if (q.file) |qf| if (qf.twin == twin) return true;
+ }
+ return false;
+}
+
+/// Whether the Edit changes file `f`: its text, its name, or its pane.
+fn changes(f: sam.File) bool {
+ return f.ops.items.len > 0 or f.get != null or f.close or f.renamed;
+}
+
+/// The Edit waiting on its commands, stopped: its write was flushed, or
+/// its connection hung up (9p_io). Its commands are killed, and nothing
+/// changes.
+pub fn cancel(p: *Pardes) void {
+ p.fs.edit_hold = null;
+ const pd = p.pipe.edit_run orelse return;
+ p.pipe.edit_run = null;
+ selection_pipe.stop(pd.request.token);
+ if (p.panes[pd.origin]) |pane| if (pane.serial == pd.origin_serial)
+ p.setMessage(pd.origin, "Edit: interrupted; nothing changed");
+ pd.deinit(p.gpa);
+}
+
+/// The held 9P write that ran the Edit has its answer: done, failed in the
+/// words said on pane `at` when `failed`.
+fn answerHold(p: *Pardes, at: usize, failed: bool) void {
+ const h = if (p.fs.edit_hold) |*held| held else return;
+ h.done = true;
+ p.fs.news = true;
+ if (!failed) return;
+ const words = if (p.fs.said_at == p.fs.failures and p.fs.said_len > 0)
+ p.fs.said[0..p.fs.said_len]
+ else if (p.panes[at]) |pane| pane.msg[0..pane.msg_len] else "Edit: failed";
+ const kept = @import("Messages.zig").clip(words, h.said.len);
+ @memcpy(h.said[0..kept.len], kept);
+ h.said_len = @intCast(kept.len);
+ h.errno = @import("ninep/ctl.zig").failureErrno(kept);
+ // The write's err record, as a refusal answered at once has.
+ pardes.ctlfs.events.noteError(p, .{ .tag = h.tag, .op = .write, .node = h.node, .handle = h.handle }, @import("ninep/tree.zig").failText(h.tag, h.errno, h.said[0..h.said_len]));
+}
+
+/// The commands answered (pipe_resp): the Edit applied, or why not.
+pub fn finish(p: *Pardes, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure) void {
+ const pd = p.pipe.edit_run orelse return;
+ p.pipe.edit_run = null;
+ defer pd.deinit(p.gpa);
+ const at = if (p.panes[pd.origin]) |pane| (if (pane.serial == pd.origin_serial) pd.origin else p.active) else p.active;
+ // Whatever fails from here fails the 9P write that ran it too.
+ const failures = p.fs.failures;
+ defer answerHold(p, at, p.fs.failures != failures);
+ if (success and outputs.len == pd.res.jobs.len) return commit(p, pd, outputs);
+
+ // Which command, in +Errors with its own words; the Edit changed nothing.
+ const job = if (failure) |f| (if (f.index < pd.res.jobs.len) pd.res.jobs[f.index] else pd.res.jobs[0]) else pd.res.jobs[0];
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ defer out.deinit();
+ const w = &out.writer;
+ var line: [256]u8 = undefined;
+ report: {
+ w.print("Edit: {c} {s}\n\n", .{ job.c, job.command }) catch break :report;
+ if (pd.res.jobs.len > 1) if (failure) |f| w.print("command {d} of {d}: ", .{ f.index + 1, pd.res.jobs.len }) catch break :report;
+ const why_at = out.written().len;
+ Pipe.writeFailure(w, failure) catch break :report;
+ const why = std.mem.sliceTo(out.written()[why_at..], '\n');
+ const said = std.fmt.bufPrint(&line, "Edit: {c} {s}: {s}", .{ job.c, job.command[0..@min(job.command.len, 120)], why }) catch "Edit: a command failed";
+ const was = p.active;
+ _ = ninep_pane.appendErrors(p, at, out.written());
+ if (p.panes[was] != null) p.active = was;
+ return p.reportFailure(at, said);
+ }
+ p.reportFailure(at, "Edit: a command failed");
+}
+
+/// Apply what the Edit came to, `outputs` its jobs' answers: refused, all
+/// of it, if a pane it changes is not the one it read any more.
+fn commit(p: *Pardes, pd: *Pending, outputs: []const []const u8) void {
+ const res = pd.res;
+ const at = if (p.panes[pd.origin]) |pane| (if (pane.serial == pd.origin_serial) pd.origin else p.active) else p.active;
+ var text: [320]u8 = undefined;
+ for (res.files, 0..) |f, i| {
+ if (f.ops.items.len == 0 and !f.close and !f.renamed and !f.moved and f.get == null) continue;
+ const pane = p.panes[f.id] orelse return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} closed while its commands ran; nothing changed", .{f.name}) catch "Edit: a pane closed");
+ if (pane.serial != pd.serials[i]) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} closed while its commands ran; nothing changed", .{f.name}) catch "Edit: a pane closed");
+ if ((f.ops.items.len > 0 or f.get != null) and pane.file.?.revision != pd.revisions[i])
+ return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} changed while its commands ran; nothing changed", .{f.name}) catch "Edit: a file changed");
+ }
+ // e first, Get's way: read before anything is changed, so one that
+ // cannot be read changes nothing; its own undo step puts the old name
+ // back with the old text, and it leaves the pane clean.
+ for (res.files) |f| {
+ const path = f.get orelse continue;
+ var failed: anyerror = error.Unexpected;
+ const errno = @import("ninep/ctl.zig").getFile(p, p.panes[f.id].?, path, &failed);
+ if (errno != 0) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: e {s}: {s}", .{ path, if (errno == @import("ninep/tree.zig").E.NOENT) "no such file" else "cannot be read" }) catch "Edit: e failed");
+ }
+ // `w`'s bytes, written after this step, before anything else is. To
+ // its own file changed on disk since it was read, as Save is: refused
+ // once, said, and written by the next (exec.saveFile); the others go.
+ var refused: std.Io.Writer.Allocating = .init(p.gpa);
+ defer refused.deinit();
+ for (res.writes) |wr| {
+ const f = res.files[wr.file];
+ const pane = p.panes[f.id].?;
+ if (pane.file) |*pf| if (!f.scratch and std.mem.eql(u8, wr.path, pf.path)) if (pf.disk_newer != null) {
+ if (!pf.disk_newer_warned) {
+ pf.disk_newer_warned = true;
+ refused.writer.print("{s}{s}", .{ if (refused.written().len > 0) ", " else "", pf.path }) catch {};
+ continue;
+ }
+ pf.disk_newer = null;
+ };
+ const path = p.gpa.dupe(u8, wr.path) catch |err| return p.reportError(at, "Edit", err);
+ const bytes = p.gpa.dupe(u8, wr.bytes) catch |err| {
+ p.gpa.free(path);
+ return p.reportError(at, "Edit", err);
+ };
+ const own = std.mem.eql(u8, wr.path, pane.file.?.path);
+ p.pipe.writes.append(p.gpa, .{
+ .pane = @intCast(f.id),
+ .serial = pane.serial,
+ .path = path,
+ .bytes = bytes,
+ // Its own name, all of it, and nothing after the w changes it:
+ // clean, as acme's putfile leaves it.
+ .clean = if (own and wr.whole and !f.scratch and f.ops.items.len == 0) pane.file.?.revision else null,
+ .promote = wr.whole and f.scratch,
+ }) catch |err| {
+ p.gpa.free(path);
+ p.gpa.free(bytes);
+ return p.reportError(at, "Edit", err);
+ };
+ }
+ if (res.writes.len > 0) p.emit(.edit_write);
+ for (res.files) |f| {
+ const pane = p.panes[f.id] orelse continue;
+ const pf = ninep_pane.fileOf(pane) orelse continue;
+ const ops = f.ops.items;
+ for (ops) |*o| if (o.job) |j| {
+ o.text = outputs[j];
+ };
+ if (ops.len > 0) {
+ const new = sam.apply(p.gpa, pf.content, ops) catch |err| return p.reportError(at, "Edit", err);
+ // `s/a/a/` changes nothing: no undo step, no new revision.
+ if (std.mem.eql(u8, new, pf.content)) p.gpa.free(new) else {
+ panes.File.pushUndo(p, pane);
+ panes.File.setContent(p, pf, new);
+ }
+ }
+ if (ops.len > 0 or f.moved) ninep_pane.setDot(pane, sam.moveDot(f.dot, ops));
+ if (f.renamed) ninep_pane.nameBuffer(p, f.id, f.name, false) catch |err| p.reportError(f.id, "Edit", err);
+ }
+ // What was printed, `>`'s output in its place among it.
+ if (res.printed.len > 0 or outputs.len > 0) {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(p.gpa);
+ var from: usize = 0;
+ for (res.jobs, 0..) |job, j| {
+ if (job.c != '>') continue;
+ out.appendSlice(p.gpa, res.printed[from..job.at]) catch return;
+ out.appendSlice(p.gpa, outputs[j]) catch return;
+ from = job.at;
+ }
+ out.appendSlice(p.gpa, res.printed[from..]) catch return;
+ // Printed, the keyboard stays where it was: a +Errors made for it
+ // does not take it, so the next Edit lands where this one did.
+ const was = p.active;
+ if (out.items.len > 0) _ = ninep_pane.appendErrors(p, at, out.items);
+ if (p.panes[was] != null) p.active = was;
+ }
+ if (refused.written().len > 0) {
+ var said: [320]u8 = undefined;
+ p.reportFailure(at, std.fmt.bufPrint(&said, "Edit: w: {s} modified on disk since read (w again to overwrite)", .{refused.written()[0..@min(refused.written().len, 200)]}) catch "Edit: w: modified on disk since read (w again to overwrite)");
+ }
+ // D's panes last, each as Del closes one: an edited one is warned about
+ // once, and closes on the next.
+ for (res.files) |f| {
+ if (!f.close) continue;
+ const pane = p.panes[f.id] orelse continue;
+ if (builtins.warnModifiedIn(.{ .p = p, .pane = pane, .id = f.id, .txt = "", .arg = null }, .Del, .{ .pane = f.id })) continue;
+ p.removePane(f.id, null) catch |err| p.reportError(f.id, "Edit", err);
+ }
+}
+
+const testing = std.testing;
+const th = @import("ninep/testing.zig");
+const tree = @import("ninep/tree.zig");
+
+/// The answer of the Edit waiting on its commands: as a host's worker
+/// brings it home, run for real when `real`.
+fn answer(p: *Pardes, outputs: []const []const u8) void {
+ const pd = p.pipe.edit_run.?;
+ p.update(.{ .pipe_resp = .{ .id = pd.id, .success = true, .outputs = outputs } });
+}
+
+fn runReal(p: *Pardes) !void {
+ const pd = p.pipe.edit_run.?;
+ const job = try selection_pipe.Job.copy(testing.allocator, pd.request);
+ defer job.deinit(testing.allocator);
+ var response = selection_pipe.runJob(testing.allocator, testing.io, job);
+ defer response.deinit(testing.allocator);
+ p.update(.{ .pipe_resp = .{ .id = pd.id, .success = response.success, .outputs = response.outputs, .failure = response.failure } });
+}
+
+/// Whether any +Errors holds `needle`.
+fn errorsHas(p: *Pardes, needle: []const u8) bool {
+ for (p.panes) |slot| {
+ const q = slot orelse continue;
+ const qf = q.file orelse continue;
+ const o = qf.output orelse continue;
+ if (std.meta.activeTag(o.from) == .errors and std.mem.indexOf(u8, qf.content, needle) != null) return true;
+ }
+ return false;
+}
+
+fn errorsText(p: *Pardes) ?[]const u8 {
+ for (p.panes) |slot| {
+ const q = slot orelse continue;
+ const qf = q.file orelse continue;
+ const o = qf.output orelse continue;
+ if (std.meta.activeTag(o.from) == .errors) return qf.content;
+ }
+ return null;
+}
+
+test "Edit , | sort runs off the loop, through the session's shell in the file's directory, and is one undo step" {
+ const p = try th.withFile(testing.allocator, "b\na\nc\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\n").reply.status);
+ // The listener is told to hold the write.
+ try testing.expect(p.fs.edit_started);
+ // Nothing changed yet: the command is out.
+ try testing.expectEqualStrings("b\na\nc\n", p.panes[0].?.file.?.content);
+ const req = p.pipe.pipeRequest(p.pipe.edit_run.?.id).?;
+ try testing.expectEqualStrings("sort", req.commands[0]);
+ try testing.expectEqualStrings("b\na\nc\n", req.inputs[0].bytes);
+ try testing.expectEqualStrings("/", req.cwds[0]);
+ try testing.expectEqualStrings(p.shellBin(), req.shell);
+ // An Edit that would change the file being filtered is busy.
+ try testing.expectEqual(tree.E.BUSY, th.wr(p, tree.Node.of(serial, .ctl), "Edit ,d\n").errno());
+ answer(p, &.{"a\nb\nc\n"});
+ try testing.expect(p.pipe.edit_run == null);
+ try testing.expectEqualStrings("a\nb\nc\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Undo\n").reply.status);
+ try testing.expectEqualStrings("b\na\nc\n", p.panes[0].?.file.?.content);
+ // Another line in its write would run before its commands answer: the
+ // whole write is refused before any line runs, the one before it too.
+ for ([_][]const u8{ "Edit , | sort\nSave\n", "Msg before\nEdit , | sort\nMsg after\n" }) |two| {
+ const after = th.wr(p, tree.Node.of(serial, .ctl), two);
+ try testing.expectEqual(tree.E.INVAL, after.errno());
+ try testing.expectEqualStrings(@import("ninep/ctl.zig").e_after_edit, after.reply.ename);
+ try testing.expect(p.pipe.edit_run == null and !th.logHas(p, "before"));
+ }
+ try testing.expectEqual(tree.E.INVAL, th.wr(p, @intFromEnum(tree.TopFile.exec), "Msg before\nEdit , | sort\n").errno());
+}
+
+test "the held write asked again is parked again, then answered once, and a flush stops the Edit" {
+ const p = try th.withFile(testing.allocator, "b\na\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ const req: tree.Req = .{ .tag = 7, .op = .write, .node = ctl, .handle = 0, .data = "Edit , | sort\n" };
+ try testing.expectEqual(tree.Status.ok, p.serveFs(req).status);
+ // As 9p_io holds it.
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 7, .node = ctl, .handle = 0, .written = @intCast(req.data.len) };
+ // A retry of its park: still waiting, never run a second time.
+ try testing.expectEqual(tree.Status.again, p.serveFs(req).status);
+ try testing.expect(p.pipe.edit_run != null);
+ answer(p, &.{"a\nb\n"});
+ try testing.expect(p.fs.edit_hold.?.done);
+ const done = p.serveFs(req);
+ try testing.expectEqual(tree.Status.ok, done.status);
+ try testing.expectEqual(@as(u32, @intCast(req.data.len)), done.written);
+ try testing.expect(p.fs.edit_hold == null);
+ try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content);
+ // A failure is the held write's answer, in the Edit's words.
+ _ = p.serveFs(.{ .tag = 8, .op = .write, .node = ctl, .data = "Edit , | false\n" });
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 2, .tag = 8, .node = ctl, .handle = 0, .written = 15 };
+ p.update(.{ .pipe_resp = .{ .id = p.pipe.edit_run.?.id, .success = false, .outputs = &.{}, .failure = .{ .kind = .exit, .code = 1 } } });
+ const failed = filesystem.EditHold.answer(&p.fs);
+ try testing.expectEqual(tree.Status.err, failed.status);
+ try testing.expectEqualStrings("Edit: | false: exit status 1", failed.ename);
+ // Flushed (or hung up): its commands stopped, nothing changed, and the
+ // next Edit is free to run; the stopped one's late answer is no one's.
+ _ = p.serveFs(.{ .tag = 9, .op = .write, .node = ctl, .data = "Edit , | sort -r\n" });
+ const stale = p.pipe.edit_run.?.id;
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 3, .tag = 9, .node = ctl, .handle = 0, .written = 18 };
+ cancel(p);
+ try testing.expect(p.pipe.edit_run == null and p.fs.edit_hold == null);
+ p.update(.{ .pipe_resp = .{ .id = stale, .success = true, .outputs = &.{"b\na\n"} } });
+ try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | cat\n").reply.status);
+}
+
+test "Edit's < | and > with a real shell: a command per address, > printed to +Errors" {
+ if (comptime !pardes.hosted) return error.SkipZigTest;
+ const p = try th.withFile(testing.allocator, "one two\nthree\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit ,x/[a-z]+/ | tr a-z A-Z\n").reply.status);
+ try testing.expectEqual(@as(usize, 3), p.pipe.edit_run.?.request.inputs.len);
+ try runReal(p);
+ try testing.expectEqualStrings("ONE TWO\nTHREE\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(usize, 1), p.panes[0].?.file.?.history.undo_len);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit 2 < printf new\n").reply.status);
+ try runReal(p);
+ try testing.expectEqualStrings("ONE TWO\nnew", p.panes[0].?.file.?.content);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , > wc -l\n").reply.status);
+ try runReal(p);
+ // The +Errors it opened did not take the keyboard.
+ try testing.expectEqual(@as(usize, 0), p.active);
+ try testing.expectEqualStrings("ONE TWO\nnew", p.panes[0].?.file.?.content);
+ try testing.expectEqualStrings("1", std.mem.trim(u8, errorsText(p).?, " \n"));
+}
+
+test "a failing command changes nothing and says why, its stderr in +Errors" {
+ if (comptime !pardes.hosted) return error.SkipZigTest;
+ const p = try th.withFile(testing.allocator, "keep\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | echo boom >&2; exit 3\n").reply.status);
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 };
+ try runReal(p);
+ try testing.expectEqualStrings("keep\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(usize, 0), p.panes[0].?.file.?.history.undo_len);
+ // The held write's answer is the failure, in its words.
+ try testing.expectEqualStrings("Edit: | echo boom >&2; exit 3: exit status 3", filesystem.EditHold.answer(&p.fs).ename);
+ const report = errorsText(p).?;
+ try testing.expect(std.mem.indexOf(u8, report, "Edit: | echo boom") != null);
+ try testing.expect(std.mem.indexOf(u8, report, "boom\n") != null);
+}
+
+test "an Edit whose file is edited while its commands run is refused whole" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | cat\n").reply.status);
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 };
+ panes.File.setContent(p, &p.panes[0].?.file.?, try testing.allocator.dupe(u8, "typed\n"));
+ answer(p, &.{"ABC\n"});
+ try testing.expectEqualStrings("typed\n", p.panes[0].?.file.?.content);
+ try testing.expectEqualStrings("Edit: /test.txt changed while its commands ran; nothing changed", filesystem.EditHold.answer(&p.fs).ename);
+}
+
+test "X over open files, w, e, r, f, B and D on panes and the disk" {
+ if (comptime !pardes.hosted or !filesystem.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 = "a.c", .data = "int foo;\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.h", .data = "foo\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.c", .data = "foo bar\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "data", .data = "DATA\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "n.c", .data = "new\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try th.withFile(gpa, "scratch\n");
+ defer p.deinit();
+ var path: [4200]u8 = undefined;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/a.c", .{dir}));
+ const a = p.active;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/b.h", .{dir}));
+ p.active = 0;
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ // X: every open file whose line matches, each from its own dot.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit X/\\.[ch]$/ ,s/foo/FOO/g\n").reply.status);
+ try testing.expectEqualStrings("int FOO;\n", p.panes[a].?.file.?.content);
+ try testing.expectEqualStrings("scratch\n", p.panes[0].?.file.?.content);
+ // X alone lists them in the order they were opened, as /index does.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit X\n").reply.status);
+ const listed = errorsText(p).?;
+ try testing.expect(std.mem.indexOf(u8, listed, "/test.txt").? < std.mem.indexOf(u8, listed, "/a.c").?);
+ try testing.expect(std.mem.indexOf(u8, listed, "/a.c").? < std.mem.indexOf(u8, listed, "/b.h").?);
+ // B opens a file, which a `"re"` address then edits, from pane 0.
+ var cmd: [4400]u8 = undefined;
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, try std.fmt.bufPrint(&cmd, "Edit B {s}/c.c\n", .{dir})).reply.status);
+ try testing.expectEqual(@as(usize, 0), p.active);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit \"c\\.c$\" 1 s/bar/BAR/\n").reply.status);
+ const c = for (p.panes, 0..) |slot, i| {
+ if (slot) |q| if (q.file) |f| if (std.mem.endsWith(u8, f.path, "/c.c")) break i;
+ } else return error.NotOpened;
+ try testing.expectEqualStrings("foo BAR\n", p.panes[c].?.file.?.content);
+ // w of all of a file to its own name leaves it clean; r reads into dot.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[a].?.serial, .ctl), "Edit w\n").reply.status);
+ p.perform(.edit_write); // the effect th.wr took off the queue
+ try testing.expectEqualStrings("int FOO;\n", p.fallback.get(p.panes[a].?.file.?.path).?);
+ try testing.expect(!ninep_pane.dirtyOf(p.panes[a].?));
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit 1 r data\n").reply.status);
+ try testing.expectEqualStrings("DATA\n", p.panes[c].?.file.?.content);
+ // e of an edited file is asked about once, as Get asks; again, it is
+ // read in Get's way, which leaves the pane clean and named after it.
+ const c_ctl = tree.Node.of(p.panes[c].?.serial, .ctl);
+ try testing.expect(th.wr(p, c_ctl, "Edit e data\n").reply.status == .err);
+ try testing.expectEqualStrings("DATA\n", p.panes[c].?.file.?.content);
+ try testing.expect(ninep_pane.dirtyOf(p.panes[c].?));
+ try testing.expectEqual(tree.Status.ok, th.wr(p, c_ctl, "Edit e data\n").reply.status);
+ try testing.expect(std.mem.endsWith(u8, p.panes[c].?.file.?.path, "/data"));
+ try testing.expect(!ninep_pane.dirtyOf(p.panes[c].?));
+ // B checks every name first: a directory among them opens none.
+ const before = p.next_serial;
+ try testing.expect(th.wr(p, ctl, try std.fmt.bufPrint(&cmd, "Edit B {s}/n.c {s}\n", .{ dir, dir })).reply.status == .err);
+ try testing.expectEqual(before, p.next_serial);
+ // f names one and prints it.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, c_ctl, "Edit 1 c/edited/\n").reply.status);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit f d.c\n").reply.status);
+ try testing.expect(std.mem.endsWith(u8, p.panes[c].?.file.?.path, "/d.c"));
+ // In its directory's +Errors, or the log when the screen has no room
+ // left for one.
+ try testing.expect(errorsHas(p, "'+. ") or th.logHas(p, "'+. "));
+ // D closes a pane by name, an edited one (b.h, by the X) on the
+ // second asking, as Del does.
+ const b_serial = for (p.panes) |slot| {
+ if (slot) |q| if (q.file) |f| if (std.mem.endsWith(u8, f.path, "/b.h")) break q.serial;
+ } else return error.NotOpened;
+ const a_ctl = tree.Node.of(p.panes[a].?.serial, .ctl);
+ try testing.expect(th.wr(p, a_ctl, "Edit D b.h\n").reply.status == .err);
+ try testing.expect(p.paneBySerial(b_serial) != null);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, a_ctl, "Edit D b.h\n").reply.status);
+ try testing.expect(p.paneBySerial(b_serial) == null);
+}
+
+test "an Edit's w to a file changed on disk is refused once, as Save is, and the others are written" {
+ if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "a\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "b\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try th.withFile(testing.allocator, "scratch\n");
+ defer p.deinit();
+ var path: [4200]u8 = undefined;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/a.txt", .{dir}));
+ const a = p.active;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/b.txt", .{dir}));
+ const b = p.active;
+ p.active = 0;
+ for ([_]usize{ a, b }) |i| try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[i].?.serial, .ctl), "Edit $a/more/\n").reply.status);
+ // a.txt changed on disk since it was read (the watch says so).
+ p.panes[a].?.file.?.disk_newer = 1;
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ const first = th.wr(p, ctl, "Edit X/'/w\n");
+ try testing.expect(first.reply.status == .err);
+ try testing.expect(std.mem.indexOf(u8, first.reply.ename, "a.txt modified on disk since read (w again to overwrite)") != null);
+ try testing.expect(std.mem.indexOf(u8, first.reply.ename, "b.txt") == null);
+ // b.txt is written all the same; a.txt is not.
+ try testing.expectEqual(@as(usize, 1), p.pipe.writes.items.len);
+ try testing.expect(std.mem.endsWith(u8, p.pipe.writes.items[0].path, "/b.txt"));
+ p.perform(.edit_write);
+ try testing.expect(ninep_pane.dirtyOf(p.panes[a].?));
+ // The same w again overwrites it, as a second Save does.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[a].?.serial, .ctl), "Edit w\n").reply.status);
+ try testing.expectEqual(@as(usize, 1), p.pipe.writes.items.len);
+ p.perform(.edit_write);
+ try testing.expect(!ninep_pane.dirtyOf(p.panes[a].?));
+}
+
+test "an Edit with no command runs beside one whose commands run, unless it touches what that one changes" {
+ if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "x\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try th.withFile(testing.allocator, "b\na\n");
+ defer p.deinit();
+ var path: [4200]u8 = undefined;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/other.txt", .{dir}));
+ const other = p.active;
+ p.active = 0;
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ const other_ctl = tree.Node.of(p.panes[other].?.serial, .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | sort\n").reply.status);
+ // Another pane's plain s runs while the filter does.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, other_ctl, "Edit ,s/x/y/\n").reply.status);
+ try testing.expectEqualStrings("y\n", p.panes[other].?.file.?.content);
+ // One that changes the filtered file, or runs commands of its own, is
+ // busy, EBUSY; a print of the filtered file is no change, and runs.
+ for ([_][]const u8{ "Edit ,s/a/A/\n", "Edit u\n" }) |line| {
+ const busy = th.wr(p, ctl, line);
+ try testing.expectEqual(tree.E.BUSY, busy.errno());
+ try testing.expect(std.mem.indexOf(u8, busy.reply.ename, "busy: ") != null);
+ }
+ try testing.expectEqual(tree.E.BUSY, th.wr(p, other_ctl, "Edit , | cat\n").errno());
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit 1p\n").reply.status);
+ answer(p, &.{"a\nb\n"});
+ try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content);
+}
+
+test "a pane an Edit's commands will change is not closed under them, and one closed anyway is named, not read freed" {
+ const p = try th.withFile(testing.allocator, "b\na\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const ctl = tree.Node.of(serial, .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit ,|sleep 0.8; cat\n").reply.status);
+ // Del, acme's delete, rmdir, Undo, Redo, Get, Zerox: busy, EBUSY.
+ for ([_][]const u8{ "delete\n", "Del\n", "Undo\n", "Redo\n", "get\n", "Get\n", "Zerox\n" }) |line| {
+ const busy = th.wr(p, ctl, line);
+ try testing.expectEqual(tree.E.BUSY, busy.errno());
+ try testing.expect(std.mem.indexOf(u8, busy.reply.ename, e_busy) != null);
+ }
+ try testing.expectEqual(tree.E.BUSY, th.rmdir(p, tree.Node.of(serial, .dir)).errno());
+ try testing.expect(p.paneBySerial(serial) != null);
+ // Closed by a way that does not ask (a session's own teardown of one
+ // pane): the Edit's end names it from its own copy.
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 };
+ try p.removePane(0, null);
+ answer(p, &.{"a\nb\n"});
+ try testing.expectEqualStrings("Edit: /test.txt closed while its commands ran; nothing changed", filesystem.EditHold.answer(&p.fs).ename);
+}
+
+test "a session that ends while an Edit's commands run lets the Edit go" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(th.serialOf(p), .ctl), "Edit ,|sleep 5; cat\n").reply.status);
+ try testing.expect(p.pipe.edit_run != null);
+ p.deinit(); // the testing allocator says if anything is kept
+}
+
+test "e and r say a file that cannot be read as Get says it" {
+ if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest;
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ for ([_][2][]const u8{
+ .{ "Edit 0r /dev/zero\n", "not a regular file" },
+ .{ "Edit 0r /tmp\n", "is a directory" },
+ .{ "Edit 0r /nonexistent-pardes-file\n", "no such file" },
+ }) |c| {
+ const r = th.wr(p, ctl, c[0]);
+ try testing.expect(r.reply.status == .err);
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, c[1]) != null);
+ }
+}