From 805b9f188768fee523d462411d846912e0877ff3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 17:43:39 -0300 Subject: Edit runs acme's file and shell commands: < | > through the session's Shell off the loop (one undo step, a failure changes nothing, its stderr in +Errors, a 9P write answered when done), X and Y over the open text panes, b B D e r f w and the "file" address; checked against sam -d, and the reference's Edit section and Coming from acme row say so Co-Authored-By: Claude Opus 5.5 --- src/9p_io.zig | 7 + src/Pipe.zig | 63 +++-- src/builtins.zig | 35 +-- src/edit_cmd.zig | 487 ++++++++++++++++++++++++++++++++++ src/fs.zig | 3 + src/ninep/ctl.zig | 7 + src/pardes.zig | 50 +++- src/sam_edit.zig | 705 ++++++++++++++++++++++++++++++++++++++++++------- src/selection_pipe.zig | 90 ++++++- 9 files changed, 1285 insertions(+), 162 deletions(-) create mode 100644 src/edit_cmd.zig (limited to 'src') diff --git a/src/9p_io.zig b/src/9p_io.zig index 68f6f6b7..671a1852 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -268,6 +268,9 @@ pub const Listener = struct { const changes = pardes.ctlfs.changesPane(core, req); const fills = pardes.ctlfs.handsOff(core, req); const reply = core.serveFs(req); + // Taken now: a request served while this one waits starts afresh. + const edit_at = core.fs.edit_answer_at; + core.fs.edit_answer_at = null; if (req.op == .release and quiet) l.collectOs(); // A close's held last line runs now when no step is out, so its err // is logged before the close is answered, as before; with a step @@ -346,6 +349,10 @@ pub const Listener = struct { } }; } + // An Edit whose `<`, `|` or `>` commands run off the loop: the write + // is answered once they have and the Edit is applied, or failed + // (late_failure), as `echo Edit , | sort > ctl` must return sorted. + if (edit_at) |n| if (l.core == core) pardes.turn.awaitEdit(n); core.fs.write_waits = false; // ponytail: one slot, so a failure of another client's effects that // settle in the same wait is told to this write too. diff --git a/src/Pipe.zig b/src/Pipe.zig index 48e98b7b..6981c023 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -8,6 +8,7 @@ const panes = @import("panes.zig"); const config = @import("config.zig"); const selection_pipe = @import("selection_pipe.zig"); const edit = @import("edit.zig"); +const edit_cmd = @import("edit_cmd.zig"); const Pane = panes.Pane; const Text = panes.Text; const Key = pardes.Key; @@ -18,6 +19,21 @@ const Pipe = @This(); seq: u32 = 0, /// The request in flight, and what it needs to apply its answer. wait: ?PendingPipe = null, +/// An Edit waiting on its `<`, `|` and `>` commands, which share the ids. +edit_run: ?*edit_cmd.Pending = null, +/// What an Edit's `w` took, for the `edit_write` effect to write. +writes: std.ArrayList(edit_cmd.Write) = .empty, + +pub fn deinit(pipe: *Pipe, gpa: std.mem.Allocator) void { + if (pipe.wait) |*wait| wait.deinit(gpa); + if (pipe.edit_run) |pending| { + pending.deinit(gpa); + // A 9P write waiting on it is answered (the core is going). + pardes.turn.editAnswered(); + } + for (pipe.writes.items) |*w| w.deinit(gpa); + pipe.writes.deinit(gpa); +} fn nextPipeEffect(p: *Pardes) ?u32 { while (p.nextEffect()) |effect| switch (effect) { @@ -164,6 +180,7 @@ pub fn pipeRequest(pipe: *const Pipe, id: u32) ?selection_pipe.Request { if (pipe.wait) |*wait| { if (wait.id == id) return wait.request(); } + if (pipe.edit_run) |pending| if (pending.id == id) return pending.request; return null; } @@ -172,26 +189,9 @@ fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Fai defer out.deinit(); const w = &out.writer; w.print("| {s}\n\n", .{wait.command}) catch return; - if (failure) |fail| { - if (wait.inputs.len > 1) - w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return; - switch (fail.kind) { - .exit => w.print("exit status {d}\n", .{fail.code}) catch return, - .signal => w.writeAll("killed by a signal\n") catch return, - .timeout => w.print( - "still running after {d} seconds, and stopped\n", - .{selection_pipe.command_timeout_seconds}, - ) catch return, - .too_large => w.writeAll("produced more output than a filter may return\n") catch return, - .spawn => w.writeAll("could not be started\n") catch return, - .io => w.writeAll("could not be read\n") catch return, - } - if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return; - } else { - // No diagnosis at all: nobody ran it. The detached daemon, the - // browser and the board all leave `pipe` null on purpose. - w.writeAll("this session cannot run filters\n") catch return; - } + if (wait.inputs.len > 1) if (failure) |fail| + w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return; + writeFailure(w, failure) catch return; const content = out.toOwnedSlice() catch return; const was = p.active; panes.Output.openErrors(p, wait.pane, content) catch |err| { @@ -203,6 +203,28 @@ fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Fai if (p.panes[was] != null) p.active = was; } +/// How a filter failed, a line, and the command's stderr after it: what +/// +Errors says of a `|` and of an Edit's `< | >`. +pub fn writeFailure(w: *std.Io.Writer, failure: ?selection_pipe.Failure) !void { + const fail = failure orelse { + // No diagnosis at all: nobody ran it. The detached daemon, the + // browser and the board all leave `pipe` null on purpose. + return w.writeAll("this session cannot run filters\n"); + }; + switch (fail.kind) { + .exit => try w.print("exit status {d}\n", .{fail.code}), + .signal => try w.writeAll("killed by a signal\n"), + .timeout => try w.print( + "still running after {d} seconds, and stopped\n", + .{selection_pipe.command_timeout_seconds}, + ), + .too_large => try w.writeAll("produced more output than a filter may return\n"), + .spawn => try w.writeAll("could not be started\n"), + .io => try w.writeAll("could not be read\n"), + } + if (fail.stderr.len > 0) try w.print("\n{s}", .{fail.stderr}); +} + fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } { const range = wait.ranges[i]; const lo = @min(range.anchor, range.head); @@ -240,6 +262,7 @@ pub fn pipeResponse( outputs: []const []const u8, failure: ?selection_pipe.Failure, ) void { + if (p.pipe.edit_run) |pending| if (pending.id == id) return edit_cmd.finish(p, success, outputs, failure); // A SUPERSEDED OR UNKNOWN id is the one silence worth keeping: it is // the answer to a question nobody is still asking. if (p.pipe.wait == null or p.pipe.wait.?.id != id) return; diff --git a/src/builtins.zig b/src/builtins.zig index da87ba37..ed6ac467 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -555,9 +555,9 @@ fn warnModified(c: Ctx, asking: Pane.Discarding) bool { /// Which panes a refusal asks about: every one (Exit, Restore), one (Del), /// or a column's (Delcol). -const Asked = union(enum) { all, pane: usize, column: usize }; +pub const Asked = union(enum) { all, pane: usize, column: usize }; -fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { +pub fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { // Every pane asked about is an `unsaved ` record, as // acme's rowclean names them all (wind.c:511); the refusal a write // gets names the one, or counts them, never a list cut short. @@ -1330,36 +1330,15 @@ pub const Togglettymode = struct { }; /// acme's Edit (exec.c `edit`, edit.c): sam's command language over the -/// pane's body, from a tag, a pane ctl or exec. One undo step; a failure -/// changes nothing and says why. src/sam_edit.zig has what is left out. +/// open text panes, from a tag, a pane ctl or exec, starting in the pane's +/// body. One undo step in each file it changes; a failure changes nothing +/// and says why. src/edit_cmd.zig runs it; src/sam_edit.zig has what is +/// left out. pub const Edit = struct { pub const takes_arg = true; pub const requires_arg = true; pub fn run(c: Ctx) void { - const sam = @import("sam_edit.zig"); - const ninep_pane = @import("ninep/pane.zig"); - const f = ninep_pane.fileOf(c.pane) orelse return c.p.reportFailure(c.id, "Edit: only a text pane is edited"); - const arena = c.p.scratch.allocator(); - var why: sam.Why = .{}; - var said: [260]u8 = undefined; - const res = sam.run(arena, f.content, ninep_pane.dotOf(c.pane), f.path, c.arg orelse "", &why) catch |err| switch (err) { - error.Edit => return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Edit: {s}", .{why.text()}) catch "Edit: failed"), - error.OutOfMemory => return c.p.reportError(c.id, "Edit", err), - }; - if (res.undo != 0) { - for (0..@abs(res.undo)) |_| if (res.undo > 0) panes.File.undo(c.p, c.pane) else panes.File.redo(c.p, c.pane); - return; - } - if (res.ops.len > 0) { - const new = sam.apply(c.p.gpa, f.content, res.ops) catch |err| return c.p.reportError(c.id, "Edit", err); - // `s/a/a/` changes nothing: no undo step, no new revision. - if (std.mem.eql(u8, new, f.content)) c.p.gpa.free(new) else { - panes.File.pushUndo(c.p, c.pane); - panes.File.setContent(c.p, f, new); - } - } - ninep_pane.setDot(c.pane, sam.moveDot(res.dot, res.ops)); - if (res.printed.len > 0) _ = ninep_pane.appendErrors(c.p, c.id, res.printed); + @import("edit_cmd.zig").run(c.p, c.id, c.arg orelse ""); } }; diff --git a/src/edit_cmd.zig b/src/edit_cmd.zig new file mode 100644 index 00000000..7f5c6279 --- /dev/null +++ b/src/edit_cmd.zig @@ -0,0 +1,487 @@ +//! 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_answer_at`). +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, + + 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(pane, id); + } + 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| { + const name = @errorName(err); + const words = if (std.mem.eql(u8, name, "FileNotFound")) + "file does not exist" + else if (std.mem.eql(u8, name, "AccessDenied") or std.mem.eql(u8, name, "PermissionDenied")) + "permission denied" + else + name; + 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; + } +}; + +fn fileOf(pane: *panes.Pane, id: usize) sam.File { + const f = ninep_pane.fileOf(pane).?; + return .{ + .name = 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"); + // acme's Edit blocks the editor while its commands run; here the + // editor goes on, and a second Edit waits its turn. + if (p.pipe.edit_run != null) return p.reportFailure(id, "Edit: another Edit's commands are still running"); + 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, column by column as acme's allwindows goes, then any + // not in a column; the Edit starts in its own. + var files: std.ArrayList(sam.File) = .empty; + var cur: usize = 0; + var seen: [pardes.MAX_PANES]bool = @splat(false); + for (0..p.ncol) |c| { + for (p.col_panes[c][0..p.col_n[c]]) |slot| addFile(p, &files, arena, slot, id, &cur, &seen) catch |err| return p.reportError(id, "Edit", err); + } + for (0..pardes.MAX_PANES) |slot| addFile(p, &files, arena, slot, id, &cur, &seen) catch |err| return p.reportError(id, "Edit", err); + + var world: World = .{ .p = p, .from = id }; + var why: sam.Why = .{}; + var text: [260]u8 = undefined; + const res = sam.run(arena, files.items, cur, command, .{ .ctx = &world, .open = World.open, .read = World.read }, &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), + }; + 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] = Pardes.paneDir(p.panes[f.id].?); + 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), + }; + keep = true; + p.pipe.edit_run = pd; + p.emit(.{ .pipe = .{ .id = pd.id } }); + // A 9P write that ran it is answered once it is done. + if (p.fs.serving) p.fs.edit_answer_at = pardes.turn.edit_answers; +} + +fn addFile(p: *Pardes, files: *std.ArrayList(sam.File), arena: std.mem.Allocator, slot: usize, from: usize, cur: *usize, seen: *[pardes.MAX_PANES]bool) !void { + if (seen[slot]) return; + seen[slot] = true; + const pane = p.panes[slot] orelse return; + if (pane.file == null) return; + if (slot == from) cur.* = files.items.len; + try files.append(arena, fileOf(pane, slot)); +} + +/// 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); + defer pardes.turn.editAnswered(); + 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 if (p.fs.failures != failures) p.noteLateFailure(at); + 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"; + _ = ninep_pane.appendErrors(p, at, out.written()); + 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) 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 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"); + } + // `w`'s bytes, written after this step, before anything else is. + for (res.writes) |wr| { + const f = res.files[wr.file]; + const pane = p.panes[f.id].?; + 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); + } + // e read its own file over it: what is on disk again. + if (f.clean) { + pf.saved_revision = pf.revision; + pf.disk_gone = false; + } + } + 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; + if (out.items.len > 0) _ = ninep_pane.appendErrors(p, at, out.items); + } + // 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 } }); +} + +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); + const answers = pardes.turn.edit_answers; + try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\n").reply.status); + // 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); + // A second Edit waits its turn rather than run beside it. + try testing.expectEqual(tree.E.IO, 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.expectEqual(answers +% 1, pardes.turn.edit_answers); + 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); + // What follows it in the same write would run before its commands + // answer: refused, and the Edit goes on. + const after = th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\nSave\n"); + try testing.expectEqual(tree.E.INVAL, after.errno()); + try testing.expectEqualStrings(@import("ninep/ctl.zig").e_after_edit, after.reply.ename); + answer(p, &.{"a\nb\nc\n"}); + try testing.expectEqualStrings("a\nb\nc\n", p.panes[0].?.file.?.content); +} + +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); + 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.late_failure_len = 0; + 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); + try testing.expectEqualStrings("Edit: | echo boom >&2; exit 3: exit status 3", p.fs.late_failure[0..p.fs.late_failure_len]); + 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(); + try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(th.serialOf(p), .ctl), "Edit , | cat\n").reply.status); + panes.File.setContent(p, &p.panes[0].?.file.?, try testing.allocator.dupe(u8, "typed\n")); + p.fs.late_failure_len = 0; + 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", p.fs.late_failure[0..p.fs.late_failure_len]); +} + +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" }); + 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); + // 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 refused; f names one and prints it. + try testing.expectEqual(tree.E.IO, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit e\n").errno()); + 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")); + try testing.expect(std.mem.indexOf(u8, errorsText(p).?, "'+. ") != null); + // 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); +} diff --git a/src/fs.zig b/src/fs.zig index 7a56d387..2a5102f4 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -1613,6 +1613,9 @@ pub const Namespace = struct { /// A 9P write asked a language server something: the answer count it /// waits to change (`turn.lsp_answers`), so its failure is the write's. lsp_answer_at: ?u64 = null, + /// A write ran an Edit whose `<`, `|` or `>` commands are running: it is + /// answered once `turn.edit_answers` passes this, the Edit done. + edit_answer_at: ?u64 = null, /// The pane a language answer filled (a Rename's preview): what the /// waiting 9P write answers once it is in (9p_io). lsp_result: ?u32 = null, diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 599dd0d6..ac5d64d8 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -203,6 +203,7 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return tree.failText(req.tag, E.INVAL, e_control); if (exec) if (tooLong(req, text)) |refusal| return refusal; if (!apply) continue; + if (p.fs.edit_answer_at != null) return tree.failText(req.tag, E.INVAL, e_after_edit); var id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; // No pane at all: the root's look and exec act in the session's // directory. An exec runs as the workspace tag's; a look from a @@ -259,6 +260,11 @@ pub fn tooLong(req: Req, line: []const u8) ?Reply { pub const e_control = "invalid command line: it holds a control character (or DEL) other than a tab"; +/// An Edit whose `<`, `|` or `>` commands are running answers its write +/// when they are done (edit_cmd.zig); a line after it in the same write +/// would run before them, on the text they are about to change. +pub const e_after_edit = "invalid command line: an Edit running commands ends its write; write what follows after it"; + /// 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. @@ -959,6 +965,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { var it = Messages.init(p, req.data); while (it.next()) |line_in| { if (line_in.len == 0 or exec_line.isComment(line_in)) continue; + if (apply and p.fs.edit_answer_at != null) return tree.failText(req.tag, E.INVAL, e_after_edit); // A line before this one may have closed the pane (`Del`). if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT); // acme's ctl words: done by the file that replaces each, or the diff --git a/src/pardes.zig b/src/pardes.zig index 7ae73636..cf3d5128 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -123,6 +123,9 @@ pub const Turn = struct { /// Bumped each time a language server's answer (or its lack) is taken /// in (`lspResponse`): a write that asked one waits for this. lsp_answers: u64 = 0, + /// Bumped each time an Edit's commands (`< | >`) have answered and the + /// Edit is done, or failed (edit_cmd.zig): a write that ran one waits. + edit_answers: u64 = 0, /// All of the above, and `out` reaching zero. settled: std.Io.Condition = .init, @@ -220,6 +223,16 @@ pub const Turn = struct { while (t.lsp_answers == answers) t.settled.wait(t.io.?, &t.mutex) catch return; } + pub fn editAnswered(t: *Turn) void { + t.edit_answers +%= 1; + const io = t.io orelse return; + t.settled.broadcast(io); + } + + pub fn awaitEdit(t: *Turn, answers: u64) void { + while (t.edit_answers == answers) t.settled.wait(t.io.?, &t.mutex) catch return; + } + pub fn awaitRestored(t: *Turn, restores: u64) void { while (t.restores == restores) t.settled.wait(t.io.?, &t.mutex) catch return; } @@ -483,6 +496,7 @@ test { _ = @import("exec.zig"); _ = @import("look.zig"); _ = @import("sam_edit.zig"); + _ = @import("edit_cmd.zig"); _ = @import("mouse.zig"); _ = @import("normal.zig"); _ = @import("regexp.zig"); @@ -3702,6 +3716,8 @@ pub const Effect = union(enum) { /// Snapshot the matching request with pipeRequest(id), then run it away /// from the UI/event loop and answer with pipe_resp. pipe: struct { id: u32 }, + /// Write what an Edit's `w` took (`Pipe.writes`), in order. + edit_write, watch: struct { pane: u8, on: bool, mode: WatchMode = .reconcile }, theme_file: struct { generation: u32, on: bool }, /// Write the build-time theme ring below the per-user config directory. @@ -4907,7 +4923,7 @@ pub const Pardes = struct { if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value); if (p.chord_arg) |a| gpa.free(a); for (p.spawn_dirs) |d| if (d) |dir| gpa.free(dir); - if (p.pipe.wait) |*wait| wait.deinit(gpa); + p.pipe.deinit(gpa); p.locations_cache.deinit(p.tree_sitter_gpa); p.fs.deinit(gpa); p.shell_rows.reset(gpa); @@ -5795,6 +5811,37 @@ pub const Pardes = struct { f(p.host.ctx, q.id) else p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), + .edit_write => { + // Each write takes the bytes `w` took when it ran: the text + // as it was before the Edit's changes. + var writes = p.pipe.writes; + p.pipe.writes = .empty; + defer { + for (writes.items) |*w| w.deinit(p.gpa); + writes.deinit(p.gpa); + } + for (writes.items) |w| { + const was = if (p.panes[w.pane]) |pane| (if (pane.file) |f| f.saved_revision else 0) else 0; + const failures = p.fs.failures; + makeParents(w.path); + p.hostWriteFile(w.pane, w.path, w.bytes); + const pane = p.panes[w.pane] orelse continue; + if (pane.serial != w.serial) continue; + const f = if (pane.file) |*file| file else continue; + // Failed, it changes nothing, as save_text's does not. + if (p.fs.failures != failures) { + f.saved_revision = was; + continue; + } + if (w.promote) { + p.promoteSaved(w.pane, w.path); + } else if (w.clean != null and w.clean.? == f.revision) { + f.saved_revision = f.revision; + f.disk_gone = false; + ctlfs.events.noteLog(p, .save, pane); + } else ctlfs.events.noteLogAs(p, .save, pane, w.path); + } + }, .watch => |w| if (v.watch_file) |f| f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on, w.mode) else { @@ -5881,6 +5928,7 @@ pub const Pardes = struct { look.cancelLookHover(p); p.fs.serving = true; p.fs.lsp_answer_at = null; + p.fs.edit_answer_at = null; const reply = ctlfs.handle(p, req); p.fs.serving = false; // The one-failure rule is a write's: a refused open (its OTRUNC's diff --git a/src/sam_edit.zig b/src/sam_edit.zig index 9b055ef9..3f23aeff 100644 --- a/src/sam_edit.zig +++ b/src/sam_edit.zig @@ -1,18 +1,29 @@ //! acme's Edit (editors/acme/edit.c, ecmd.c, elog.c): sam's command -//! language over one pane's body. The commands are parsed as edit.c parses -//! them, run as ecmd.c runs them against the text as it was before the Edit, -//! and their changes collected in order, as elog.c logs them, then applied -//! at once: an Edit is one undo step, and one that fails changes nothing. +//! language over the open text panes. The commands are parsed as edit.c +//! parses them, run as ecmd.c runs them against the text as it was before +//! the Edit, and their changes collected in order, a log a file, as elog.c +//! logs them, then applied at once: an Edit is one undo step in each file it +//! changes, and one that fails changes nothing. //! //! Addresses are the `addr` file's (src/ninep/addr.zig), so they take //! pardes's `line:col` too; regular expressions are src/regexp.zig's, mvzr //! searched as sam searches, with its step budget. //! -//! Left out: the file commands (`b B D e r w f X Y`), the pipes (`< | >`), -//! the `'` and `"` addresses, and `\1`..`\9` in `s` (mvzr has no -//! submatches). `u` undoes or redoes only as a whole Edit. acme warns about -//! changes out of sequence (elog.c:130) and applies them anyway; here the -//! Edit is refused. +//! What touches more than the text is the caller's (src/EditCmd.zig): `<`, +//! `|` and `>` are collected as jobs, a command to run per address, whose +//! output fills the change they hold the place of; `w` as the bytes to +//! write; `D`, and the names `e` and `f` set, as marks on the file; `B` and +//! the reads of `e` and `r` through `World`, at once, as acme does them. +//! +//! Left out: the `'` address, `B `) refuses the whole Edit, where sam +//! warns and puts in what it wrote; `D` of a file the Edit changed is +//! refused rather than drop the changes; `e` of an edited file is refused +//! every time, not once. const std = @import("std"); const regexp = @import("regexp.zig"); const addr_lang = @import("ninep/addr.zig"); @@ -26,14 +37,62 @@ fn stepRune(text: []const u8, p: usize) usize { pub const Range = addr_lang.Range; -pub const Op = struct { q0: u32, q1: u32, text: []const u8 }; +/// A change to the text as it was: `job`, when set, is a `<` or `|` whose +/// command's output is the text, known once it has run. +pub const Op = struct { q0: u32, q1: u32, text: []const u8, job: ?u32 = null }; + +/// One open text pane, as an Edit sees and leaves it. +pub const File = struct { + /// Its absolute path; after `e` or `f`, the name they gave it. + name: []const u8, + text: []const u8, + dot: Range = .{}, + /// Edited since it was saved: the `'` of acme's file line. + dirty: bool = false, + /// No file of its own (a +Errors, a +New): `e`, `r` and `w` want a name. + scratch: bool = false, + /// The caller's: which pane it is. + id: usize = 0, + ops: std.ArrayList(Op) = .empty, + /// `e` or `f` named it `name`. + renamed: bool = false, + /// `D` closes it once the Edit is done. + close: bool = false, + /// `e` read its own file whole: it is clean again once applied. + clean: bool = false, + /// A command left a dot in it. + moved: bool = false, +}; + +/// A command for `<`, `|` or `>` (`c`), run in `file`'s directory, its +/// stdin `file`'s text in `q0..q1` (none for `<`). `>`'s output is printed +/// at `at` in `Result.printed`. +pub const Job = struct { c: u8, command: []const u8, file: u32, q0: u32, q1: u32, at: usize }; -/// What an Edit came to: the changes in order, the dot it leaves (in the -/// text before them), and what `p` and `=` printed. +/// `w`: `bytes` to `path`, `whole` when they are all of `file`'s text. +pub const Write = struct { file: u32, path: []const u8, bytes: []const u8, whole: bool }; + +/// What the editor does for `B`, `e` and `r` while an Edit runs. +pub const World = struct { + ctx: *anyopaque, + /// The text pane holding the absolute `path`, opened if it is not open: + /// `B`. Null, and `why` set, when it cannot be. + open: *const fn (ctx: *anyopaque, path: []const u8, why: *Why) ?File, + /// The bytes of the absolute `path`, in `arena`: `e` and `r`. + read: *const fn (ctx: *anyopaque, arena: std.mem.Allocator, path: []const u8, why: *Why) ?[]const u8, +}; + +/// What an Edit came to: every file (those `B` opened after the ones it +/// was given), each with its changes in order and the dot it leaves (in the +/// text before them); the jobs and writes; and what `p`, `=`, `b`, `f` and +/// `X` printed. pub const Result = struct { - ops: []const Op, - dot: Range, - printed: []const u8, + files: []File, + /// The current file at the end (`b` moves it). + cur: usize, + jobs: []const Job = &.{}, + writes: []const Write = &.{}, + printed: []const u8 = "", /// A bare `u`/`u-3`: undo (positive) or redo (negative) that many. undo: i32 = 0, }; @@ -49,9 +108,17 @@ pub const Why = struct { } }; +/// The most commands an Edit's `<`, `|` and `>` may run: each is a process. +pub const max_jobs = 1024; + const Cmd = struct { c: u8, addr: ?[]const u8 = null, + /// A `"re"` address: the file whose line matches. + file_re: ?[]const u8 = null, + /// A `,` after it, whose right side acme evaluates in the current file + /// (ecmd.c cmdaddress): only `;` carries the file over. + file_comma: bool = false, re: ?[]const u8 = null, text: []const u8 = "", sub: ?*Cmd = null, @@ -61,9 +128,10 @@ const Cmd = struct { to: ?[]const u8 = null, }; -/// Runs `command` over `text` from `dot`, the file named `name` (for `=`). +/// Runs `command` over `files` from `files[cur]`, whose dots are where +/// each starts. `world` answers `B`, `e` and `r`; with none they fail. /// Everything returned lives in `arena`. -pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const u8, command: []const u8, why: *Why) Failure!Result { +pub fn run(arena: std.mem.Allocator, files: []const File, cur: usize, command: []const u8, world: ?World, why: *Why) Failure!Result { var src = try arena.alloc(u8, command.len + 1); @memcpy(src[0..command.len], command); src[command.len] = '\n'; @@ -80,23 +148,32 @@ pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const // reaches Edit unfinished was cut short (a write, an open closed), and // half of it inserted is worse than none. if (ps.open_text) return fail(why, "a, c or i text not ended by a . line", .{}); - const cmds = first orelse return .{ .ops = &.{}, .dot = dot, .printed = "" }; + var ex: Exec = .{ .arena = arena, .why = why, .world = world, .cur = cur }; + try ex.files.appendSlice(arena, files); + const cmds = first orelse return .{ .files = ex.files.items, .cur = cur }; // acme's u runs at once (ecmd.c:532), outside the edit log; here it is // an Edit of its own. if (cmds.c == 'u') { if (cmds.next != null) return fail(why, "u is only an Edit of its own here", .{}); - return .{ .ops = &.{}, .dot = dot, .printed = "", .undo = cmds.num }; + return .{ .files = ex.files.items, .cur = cur, .undo = cmds.num }; } - var ex: Exec = .{ .arena = arena, .text = text, .name = name, .why = why }; - var d = dot; + // editthread: each command from the current file, which `b` changes. var it: ?*Cmd = cmds; - while (it) |c| : (it = c.next) d = try ex.exec(c, d); - return .{ .ops = ex.ops.items, .dot = d, .printed = ex.out.items }; + while (it) |c| : (it = c.next) { + const fi = ex.cur; + const d = try ex.exec(c, fi, ex.files.items[fi].dot); + ex.files.items[fi].dot = d; + ex.files.items[fi].moved = true; + } + return .{ + .files = ex.files.items, + .cur = ex.cur, + .jobs = ex.jobs.items, + .writes = ex.writes.items, + .printed = ex.out.items, + }; } -/// Whether `command` stops inside a `{` group or an `a`, `c` or `i` text -/// block still waiting for its `.` line: a writer of lines (a ctl, exec) -/// hands an Edit the lines after it until it does not. /// What an Edit command still waits for: nothing, the `.` line that ends /// an a, c or i text block, or the `}` of a group. pub const Wait = enum { none, text, group }; @@ -131,6 +208,9 @@ fn groupOpen(command: []const u8) bool { return depth > 0; } +/// Whether `command` stops inside a `{` group or an `a`, `c` or `i` text +/// block still waiting for its `.` line: a writer of lines (a ctl, exec) +/// hands an Edit the lines after it until it does not. pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool { var why: Why = .{}; const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false; @@ -158,6 +238,8 @@ const Parser = struct { braced: bool = false, /// A text block of `a`, `c` or `i` ran out before its `.` line. open_text: bool = false, + /// The address being read had a `,` after a `"re"`. + file_comma: bool = false, why: *Why, fn getch(ps: *Parser) ?u8 { @@ -207,8 +289,9 @@ const Parser = struct { } /// An address's text, blanks dropped, or null: the syntax of edit.c's - /// compoundaddr and simpleaddr, evaluated later by addr.zig. - fn address(ps: *Parser) Failure!?[]const u8 { + /// compoundaddr and simpleaddr, evaluated later by addr.zig. A `"re"` + /// first is the file the rest addresses, kept in `file_re`. + fn address(ps: *Parser, file_re: ?*?[]const u8) Failure!?[]const u8 { var out: std.ArrayList(u8) = .empty; while (true) { const c = ps.skipbl() orelse break; @@ -216,6 +299,7 @@ const Parser = struct { '0'...'9', '#', '.', '$', '+', '-', ',', ';' => { ps.i += 1; try out.append(ps.arena, c); + if (c == ',' and file_re != null and file_re.?.* != null) ps.file_comma = true; // pardes's line:col if (c >= '0' and c <= '9' and ps.nextc() == ':' and ps.i + 1 < ps.src.len and ps.src[ps.i + 1] >= '0' and ps.src[ps.i + 1] <= '9') @@ -242,7 +326,13 @@ const Parser = struct { } }, '\'' => return fail(ps.why, "can't handle '", .{}), - '"' => return fail(ps.why, "a file address is no address pardes's Edit takes", .{}), + '"' => { + // Only first, as the file the rest of it addresses. + const into = file_re orelse return fail(ps.why, "bad address", .{}); + if (out.items.len > 0 or into.* != null) return fail(ps.why, "bad address", .{}); + ps.i += 1; + into.* = try ps.getregexp('"'); + }, else => break, } } @@ -322,13 +412,33 @@ const Parser = struct { return s; } + /// edit.c:404, collecttoken: up to a byte of `end`, blanks before it + /// kept (the command trims them); a word wants its newline after it. + fn collecttoken(ps: *Parser, end: []const u8) Failure![]const u8 { + const start = ps.i; + while (ps.nextc()) |c| : (ps.i += 1) { + if (c != ' ' and c != '\t') break; + } + while (ps.nextc()) |c| : (ps.i += 1) { + if (std.mem.indexOfScalar(u8, end, c) != null) break; + } + const s = ps.src[start..ps.i]; + if (ps.nextc() == '\n') ps.i += 1 else try ps.atnl(); + return s; + } + /// edit.c:467, parsecmd. fn parse(ps: *Parser, nest: u32) Failure!?*Cmd { - const a = try ps.address(); + var file_re: ?[]const u8 = null; + ps.file_comma = false; + const a = try ps.address(&file_re); _ = ps.skipbl() orelse return null; const c = ps.getch() orelse return null; const cmd = try ps.arena.create(Cmd); - cmd.* = .{ .c = c, .addr = a }; + cmd.* = .{ .c = c, .addr = a, .file_re = file_re, .file_comma = ps.file_comma }; + // aNo in edit.c's cmdtab. + if ((a != null or file_re != null) and std.mem.indexOfScalar(u8, "bBDefuXY", c) != null) + return fail(ps.why, "command takes no address", .{}); switch (c) { '\n' => {}, 'a', 'c', 'i' => cmd.text = try ps.collecttext(), @@ -362,10 +472,11 @@ const Parser = struct { } try ps.atnl(); }, - 'x', 'y', 'g', 'v' => { - // x without a pattern is every line (ecmd.c:568, linelooper). + 'x', 'y', 'g', 'v', 'X', 'Y' => { + // x without a pattern is every line (ecmd.c:568, + // linelooper), X every file. const n = ps.nextc(); - if (c != 'x' or (n != ' ' and n != '\t' and n != '\n')) { + if ((c != 'x' and c != 'X') or (n != ' ' and n != '\t' and n != '\n')) { _ = ps.skipbl(); const delim = ps.getch() orelse '\n'; if (delim == '\n') return fail(ps.why, "no address", .{}); @@ -375,21 +486,23 @@ const Parser = struct { if (ps.skipbl() == '\n') { ps.i += 1; const p = try ps.arena.create(Cmd); - p.* = .{ .c = 'p' }; + p.* = .{ .c = if (c == 'X' or c == 'Y') 'f' else 'p' }; cmd.sub = p; } else { cmd.sub = (try ps.parse(nest)) orelse return fail(ps.why, "defcmd", .{}); } }, 'm', 't' => { - cmd.to = (try ps.address()) orelse return fail(ps.why, "bad address", .{}); + cmd.to = (try ps.address(null)) orelse return fail(ps.why, "bad address", .{}); try ps.atnl(); }, 'u' => { - if (a != null) return fail(ps.why, "command takes no address", .{}); cmd.num = ps.getnum(2); try ps.atnl(); }, + // edit.c's cmdtab: a line for these, a word for those. + 'b', 'B', 'D', '<', '|', '>' => cmd.text = try ps.collecttoken("\n"), + 'e', 'f', 'r', 'w' => cmd.text = try ps.collecttoken(" \t\n"), '{' => { var last: ?*Cmd = null; while (true) { @@ -410,7 +523,6 @@ const Parser = struct { if (nest == 0) return fail(ps.why, "right brace with no left brace", .{}); return null; }, - 'b', 'B', 'D', 'e', 'r', 'w', 'f', 'X', 'Y', '<', '|', '>' => return fail(ps.why, "{c} is a sam command pardes's Edit leaves out (it has no file or shell commands)", .{c}), else => return fail(ps.why, "unknown command {c}", .{c}), } return cmd; @@ -419,23 +531,35 @@ const Parser = struct { // ---- running: ecmd.c, logging: elog.c ---- +const e_noname = "no file name given"; + const Exec = struct { arena: std.mem.Allocator, - text: []const u8, - name: []const u8, why: *Why, - ops: std.ArrayList(Op) = .empty, + world: ?World, + files: std.ArrayList(File) = .empty, + /// The current file: where the next top-level command runs. + cur: usize, + jobs: std.ArrayList(Job) = .empty, + writes: std.ArrayList(Write) = .empty, out: std.ArrayList(u8) = .empty, nest: u32 = 0, + /// Inside an X or Y, which do not nest (ecmd.c Glooping). + looping: bool = false, fn clip(n: usize) u32 { return std.math.cast(u32, n) orelse std.math.maxInt(u32); } - /// An address from `dot`, or `dot` when there is none (aDot). - fn address(ex: *Exec, expr: ?[]const u8, dot: Range) Failure!Range { + fn text(ex: *const Exec, fi: usize) []const u8 { + return ex.files.items[fi].text; + } + + /// An address in file `fi` from `dot`, or `dot` when there is none + /// (aDot). + fn address(ex: *Exec, fi: usize, expr: ?[]const u8, dot: Range) Failure!Range { const e = expr orelse return dot; - var a: addr_lang.Addr = .{ .text = ex.text, .lim = null, .expr = e }; + var a: addr_lang.Addr = .{ .text = ex.text(fi), .lim = null, .expr = e }; // A miss names the pattern that missed, not the whole Edit line. const r = a.address(dot) orelse return if (std.mem.eql(u8, a.err, addr_lang.e_no_match)) fail(ex.why, "no match for regexp {s}", .{e}) @@ -448,13 +572,14 @@ const Exec = struct { /// elog.c's order: each change at or after the end of the one before, /// since they all address the text as it was. - fn log(ex: *Exec, q0: u32, q1: u32, text: []const u8) Failure!void { - if (q0 == q1 and text.len == 0) return; - if (ex.ops.items.len > 0) { - const prev = ex.ops.items[ex.ops.items.len - 1]; + fn log(ex: *Exec, fi: usize, q0: u32, q1: u32, bytes: []const u8, job: ?u32) Failure!void { + if (q0 == q1 and bytes.len == 0 and job == null) return; + const ops = &ex.files.items[fi].ops; + if (ops.items.len > 0) { + const prev = ops.items[ops.items.len - 1]; if (q0 < prev.q1 or q0 < prev.q0) return fail(ex.why, "changes out of sequence", .{}); } - try ex.ops.append(ex.arena, .{ .q0 = q0, .q1 = q1, .text = text }); + try ops.append(ex.arena, .{ .q0 = q0, .q1 = q1, .text = bytes, .job = job }); } fn compile(ex: *Exec, pat: []const u8, c: u8) Failure!regexp.Regex { @@ -464,7 +589,10 @@ const Exec = struct { error.WideRange => fail(ex.why, "{s}", .{regexp.Regex.e_wide}), error.NegatedRunes => fail(ex.why, "{s}", .{regexp.Regex.e_negated}), error.Mixed => fail(ex.why, "{s}", .{regexp.Regex.e_mixed}), - error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}), + error.Bad => if (c == '"' or c == 'X' or c == 'Y') + fail(ex.why, "bad regexp in file match", .{}) + else + fail(ex.why, "bad regexp in {c} command", .{c}), }; } @@ -472,84 +600,175 @@ const Exec = struct { /// (addr.zig): mvzr matches bytes, so `.` would take one byte of `é` /// and x, y and s would split it. From `from` on only, so a match never /// reaches back into what the last one took. - fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match { + fn find(ex: *Exec, rx: *regexp.Regex, in: []const u8, from: usize, hi: usize) Failure!?regexp.Regex.Match { if (from > hi) return null; // The budget is each search's own: an x over 100k lines makes 100k // searches, none of which is the slow one. rx.steps = regexp.Regex.budget; - const m = (rx.find(ex.text, from, hi, hi) catch return fail(ex.why, "{s}", .{addr_lang.e_slow})) orelse return null; - var start = modal.runeStart(ex.text, m.start); - if (start < from) start = modal.runeEnd(ex.text, m.start); - return .{ .start = start, .end = @max(start, modal.runeEnd(ex.text, m.end)) }; + const m = (rx.find(in, from, hi, hi) catch return fail(ex.why, "{s}", .{addr_lang.e_slow})) orelse return null; + var start = modal.runeStart(in, m.start); + if (start < from) start = modal.runeEnd(in, m.start); + return .{ .start = start, .end = @max(start, modal.runeEnd(in, m.end)) }; + } + + /// ecmd.c pfilename: `'` if edited, `+`, `.` if current, and the name. + fn fileLine(ex: *Exec, fi: usize) Failure![]const u8 { + const f = &ex.files.items[fi]; + return std.fmt.allocPrint(ex.arena, "{c}+{c} {s}\n", .{ + @as(u8, if (f.dirty and !f.scratch) '\'' else ' '), + @as(u8, if (fi == ex.cur) '.' else ' '), + f.name, + }); + } + + /// ecmd.c filematch: the file's line matches `pat`. + fn fileMatch(ex: *Exec, fi: usize, pat: []const u8) Failure!bool { + var rx = try ex.compile(pat, 'X'); + const line = try ex.fileLine(fi); + return (try ex.find(&rx, line, 0, line.len)) != null; + } + + /// ecmd.c matchfile: the one open file whose line matches `pat`. + fn matchFile(ex: *Exec, pat: []const u8) Failure!usize { + var found: ?usize = null; + for (ex.files.items, 0..) |f, i| { + if (f.close or f.scratch) continue; + if (!try ex.fileMatch(i, pat)) continue; + if (found != null) return fail(ex.why, "too many files match \"{s}\"", .{pat}); + found = i; + } + return found orelse fail(ex.why, "no file matches \"{s}\"", .{pat}); + } + + /// `name` as a path: itself when absolute, else in `fi`'s directory + /// (acme's dirname). + fn pathIn(ex: *Exec, fi: usize, name: []const u8) Failure![]const u8 { + if (name.len > 0 and name[0] == '/') return name; + const dir = std.fs.path.dirnamePosix(ex.files.items[fi].name) orelse "/"; + return std.fs.path.resolvePosix(ex.arena, &.{ dir, name }); + } + + /// The open file named `path`, a scratch too. + fn lookFile(ex: *Exec, path: []const u8) ?usize { + for (ex.files.items, 0..) |f, i| if (!f.close and std.mem.eql(u8, f.name, path)) return i; + return null; + } + + /// ecmd.c cmdname: the name a file command means: the one given, in the + /// file's directory, else the file's own (none for a scratch). `set` + /// names the file so (`e`, `f`), as an unnamed one always is. + fn cmdName(ex: *Exec, fi: usize, given: []const u8, set: bool) Failure!?[]const u8 { + const s = std.mem.trim(u8, given, " \t"); + const f = &ex.files.items[fi]; + if (s.len == 0) return if (f.scratch) null else f.name; + const full = try ex.pathIn(fi, s); + if (set and !std.mem.eql(u8, full, f.name)) { + if (ex.lookFile(full)) |other| if (other != fi) + try ex.out.print(ex.arena, "warning: duplicate file name \"{s}\"\n", .{full}); + f.name = full; + f.renamed = true; + f.scratch = false; + } + return full; + } + + fn readFile(ex: *Exec, path: []const u8) Failure![]const u8 { + const w = ex.world orelse return fail(ex.why, "can't open {s}: no files here", .{path}); + return w.read(w.ctx, ex.arena, path, ex.why) orelse error.Edit; + } + + /// ecmd.c:62, cmdexec: runs `c` in file `fi` from `dot`, answering the + /// dot it leaves there. + fn exec(ex: *Exec, c: *Cmd, fi_in: usize, dot_in: Range) Failure!Range { + var fi = fi_in; + var dot = dot_in; + if (ex.files.items[fi].close) return fail(ex.why, "no current window", .{}); + // A `"re"` address: the command runs in that file, from its dot. + if (c.file_re) |pat| { + fi = try ex.matchFile(pat); + dot = ex.files.items[fi].dot; + if (c.file_comma and fi != fi_in) return fail(ex.why, "addresses in different files", .{}); + } + // w's default address is the whole file (aAll). + const r = if (c.c == 'w' and c.addr == null) + Range{ .q0 = 0, .q1 = clip(ex.text(fi).len) } + else + try ex.address(fi, c.addr, dot); + const d = try ex.execIn(c, fi, r); + if (fi == fi_in) return d; + ex.files.items[fi].dot = d; + ex.files.items[fi].moved = true; + return dot_in; } - /// ecmd.c:62, cmdexec: runs `c` from `dot`, answering the dot it leaves. - fn exec(ex: *Exec, c: *Cmd, dot: Range) Failure!Range { - const r = try ex.address(c.addr, dot); + /// The command itself, at `r` in file `fi`. + fn execIn(ex: *Exec, c: *Cmd, fi: usize, r: Range) Failure!Range { + const txt = ex.text(fi); switch (c.c) { '\n' => return r, 'a' => { - try ex.log(r.q1, r.q1, c.text); + try ex.log(fi, r.q1, r.q1, c.text, null); return .{ .q0 = r.q1, .q1 = r.q1 }; }, 'i' => { - try ex.log(r.q0, r.q0, c.text); + try ex.log(fi, r.q0, r.q0, c.text, null); return .{ .q0 = r.q0, .q1 = r.q0 }; }, 'c' => { - try ex.log(r.q0, r.q1, c.text); + try ex.log(fi, r.q0, r.q1, c.text, null); return .{ .q0 = r.q0, .q1 = r.q0 }; }, 'd' => { - try ex.log(r.q0, r.q1, ""); + try ex.log(fi, r.q0, r.q1, "", null); return .{ .q0 = r.q0, .q1 = r.q0 }; }, 'p' => { - try ex.out.appendSlice(ex.arena, ex.text[r.q0..r.q1]); + try ex.out.appendSlice(ex.arena, txt[r.q0..r.q1]); return r; }, '=' => { // ecmd.c:665, printposn + const name = ex.files.items[fi].name; if (c.text.len > 0) { - try ex.out.print(ex.arena, "{s}:#{d}", .{ ex.name, r.q0 }); + try ex.out.print(ex.arena, "{s}:#{d}", .{ name, r.q0 }); if (r.q1 != r.q0) try ex.out.print(ex.arena, ",#{d}", .{r.q1}); } else { - const l1 = 1 + std.mem.count(u8, ex.text[0..r.q0], "\n"); - var l2 = l1 + std.mem.count(u8, ex.text[r.q0..r.q1], "\n"); - if (r.q1 > r.q0 and ex.text[r.q1 - 1] == '\n') l2 -= 1; - try ex.out.print(ex.arena, "{s}:{d}", .{ ex.name, l1 }); + const l1 = 1 + std.mem.count(u8, txt[0..r.q0], "\n"); + var l2 = l1 + std.mem.count(u8, txt[r.q0..r.q1], "\n"); + if (r.q1 > r.q0 and txt[r.q1 - 1] == '\n') l2 -= 1; + try ex.out.print(ex.arena, "{s}:{d}", .{ name, l1 }); if (l2 != l1) try ex.out.print(ex.arena, ",{d}", .{l2}); } try ex.out.append(ex.arena, '\n'); return r; }, - 's' => return ex.substitute(c, r), + 's' => return ex.substitute(c, fi, r), 'x', 'y' => { - const ranges = if (c.re) |pat| try ex.looper(pat, r, c.c == 'x') else try ex.lines(r); + const ranges = if (c.re) |pat| try ex.looper(pat, fi, r, c.c == 'x') else try ex.lines(fi, r); ex.nest += 1; defer ex.nest -= 1; var d = r; - for (ranges) |each| d = try ex.exec(c.sub.?, each); + for (ranges) |each| d = try ex.exec(c.sub.?, fi, each); return d; }, 'g', 'v' => { var rx = try ex.compile(c.re.?, c.c); - const hit = (try ex.find(&rx, r.q0, r.q1)) != null; - if (hit != (c.c == 'v')) return ex.exec(c.sub.?, r); + const hit = (try ex.find(&rx, txt, r.q0, r.q1)) != null; + if (hit != (c.c == 'v')) return ex.exec(c.sub.?, fi, r); return r; }, 'm', 't' => { - const to = try ex.address(c.to, dot); - const bytes = ex.text[r.q0..r.q1]; + const to = try ex.address(fi, c.to, r); + const bytes = txt[r.q0..r.q1]; if (c.c == 't') { - try ex.log(to.q1, to.q1, bytes); + try ex.log(fi, to.q1, to.q1, bytes, null); } else if (r.q1 <= to.q0) { // ecmd.c:408, move - try ex.log(r.q0, r.q1, ""); - try ex.log(to.q1, to.q1, bytes); + try ex.log(fi, r.q0, r.q1, "", null); + try ex.log(fi, to.q1, to.q1, bytes, null); } else if (r.q0 >= to.q1) { - try ex.log(to.q1, to.q1, bytes); - try ex.log(r.q0, r.q1, ""); + try ex.log(fi, to.q1, to.q1, bytes, null); + try ex.log(fi, r.q0, r.q1, "", null); } else if (!(r.q0 == to.q0 and r.q1 == to.q1)) { return fail(ex.why, "move overlaps itself", .{}); } @@ -558,28 +777,142 @@ const Exec = struct { 'u' => return fail(ex.why, "u is only an Edit of its own here", .{}), '{' => { var it = c.sub; - while (it) |each| : (it = each.next) _ = try ex.exec(each, r); + while (it) |each| : (it = each.next) _ = try ex.exec(each, fi, r); + return r; + }, + 'b' => { + // ecmd.c b_cmd, tofile: the file named so, exactly or in + // the current file's directory. + const name = std.mem.trim(u8, c.text, " \t"); + const at = ex.lookFile(name) orelse ex.lookFile(try ex.pathIn(fi, name)); + const to = if (at) |i| (if (ex.files.items[i].scratch) null else i) else null; + const target = to orelse return fail(ex.why, "no such file \"{s}\"", .{name}); + // The `.` goes with it in the line printed. + ex.cur = target; + if (ex.nest == 0) try ex.out.appendSlice(ex.arena, try ex.fileLine(target)); + return r; + }, + 'B' => { + const list = std.mem.trim(u8, c.text, " \t"); + if (list.len > 0 and list[0] == '<') return fail(ex.why, "B { + const list = std.mem.trim(u8, c.text, " \t"); + if (list.len > 0 and list[0] == '<') return fail(ex.why, "D { + // ecmd.c e_cmd: e reads a file over all of this one, r over + // dot; e names this file after it. + const f = &ex.files.items[fi]; + if (c.c == 'e' and f.dirty and !f.scratch) return fail(ex.why, "{s} modified", .{f.name}); + const own = f.name; + const path = (try ex.cmdName(fi, c.text, c.c == 'e')) orelse return fail(ex.why, e_noname, .{}); + const bytes = try ex.readFile(path); + const q: Range = if (c.c == 'e') .{ .q0 = 0, .q1 = clip(txt.len) } else r; + try ex.log(fi, q.q0, q.q1, bytes, null); + if (c.c == 'e' and std.mem.eql(u8, path, own)) ex.files.items[fi].clean = true; + return r; + }, + 'w' => { + if (ex.files.items[fi].ops.items.len > 0) return fail(ex.why, "can't write file with pending modifications", .{}); + const path = (try ex.cmdName(fi, c.text, false)) orelse return fail(ex.why, "no name specified for 'w' command", .{}); + try ex.writes.append(ex.arena, .{ + .file = @intCast(fi), + .path = path, + .bytes = try ex.arena.dupe(u8, txt[r.q0..r.q1]), + .whole = r.q0 == 0 and r.q1 == txt.len, + }); + return r; + }, + 'f' => { + _ = try ex.cmdName(fi, c.text, true); + try ex.out.appendSlice(ex.arena, try ex.fileLine(fi)); + return r; + }, + 'X', 'Y' => { + // ecmd.c filelooper: the files whose line matches (X) or + // does not (Y), each from its own dot; X alone is every + // file with a name. + if (ex.looping) return fail(ex.why, "can't nest {c} command", .{c.c}); + ex.looping = true; + defer ex.looping = false; + ex.nest += 1; + defer ex.nest -= 1; + var chosen: std.ArrayList(usize) = .empty; + for (ex.files.items, 0..) |f, i| { + if (f.close) continue; + if (c.re == null and f.scratch) continue; + if (c.re == null or (try ex.fileMatch(i, c.re.?)) == (c.c == 'X')) try chosen.append(ex.arena, i); + } + for (chosen.items) |i| { + const d = try ex.exec(c.sub.?, i, ex.files.items[i].dot); + ex.files.items[i].dot = d; + ex.files.items[i].moved = true; + } + return r; + }, + '<', '|', '>' => { + // ecmd.c runpipe: the command's output replaces dot (`<`, + // `|`), dot is its input (`|`, `>`), and `>`'s output is + // printed. + const command = std.mem.trim(u8, c.text, " \t"); + if (command.len == 0) return fail(ex.why, "no command specified for {c}", .{c.c}); + if (ex.jobs.items.len >= max_jobs) return fail(ex.why, "too many commands: more than {d} for one Edit", .{max_jobs}); + const job: u32 = @intCast(ex.jobs.items.len); + try ex.jobs.append(ex.arena, .{ .c = c.c, .command = command, .file = @intCast(fi), .q0 = r.q0, .q1 = r.q1, .at = ex.out.items.len }); + if (c.c != '>') try ex.log(fi, r.q0, r.q1, "", job); return r; }, else => return fail(ex.why, "unknown command {c}", .{c.c}), } } + /// ecmd.c D1: closed once the Edit is done. One with changes pending + /// in this Edit is refused rather than have them dropped. + fn closeFile(ex: *Exec, fi: usize) Failure!void { + const f = &ex.files.items[fi]; + if (f.ops.items.len > 0) return fail(ex.why, "D: {s} has changes pending in this Edit", .{f.name}); + f.close = true; + } + /// ecmd.c:443, s_cmd. - fn substitute(ex: *Exec, c: *Cmd, r: Range) Failure!Range { + fn substitute(ex: *Exec, c: *Cmd, fi: usize, r: Range) Failure!Range { + const txt = ex.text(fi); var rx = try ex.compile(c.re.?, 's'); var n = c.num; var op: ?usize = null; var p1: usize = r.q0; var did = false; while (p1 <= r.q1) { - const m = (try ex.find(&rx, p1, r.q1)) orelse break; + const m = (try ex.find(&rx, txt, p1, r.q1)) orelse break; if (m.start == m.end) { if (op != null and m.start == op.?) { - p1 = stepRune(ex.text, p1); + p1 = stepRune(txt, p1); continue; } - p1 = stepRune(ex.text, m.end); + p1 = stepRune(txt, m.end); } else p1 = m.end; op = m.end; n -= 1; @@ -594,10 +927,10 @@ const Exec = struct { if (e >= '1' and e <= '9') return fail(ex.why, "no \\{c}: mvzr keeps no submatches", .{e}); try buf.append(ex.arena, e); } else if (ch == '&') { - try buf.appendSlice(ex.arena, ex.text[m.start..m.end]); + try buf.appendSlice(ex.arena, txt[m.start..m.end]); } else try buf.append(ex.arena, ch); } - try ex.log(clip(m.start), clip(m.end), buf.items); + try ex.log(fi, clip(m.start), clip(m.end), buf.items, null); did = true; if (!c.global) break; } @@ -609,7 +942,8 @@ const Exec = struct { } /// ecmd.c:796, looper: the matches (x) or what lies between them (y). - fn looper(ex: *Exec, pat: []const u8, r: Range, xy: bool) Failure![]Range { + fn looper(ex: *Exec, pat: []const u8, fi: usize, r: Range, xy: bool) Failure![]Range { + const txt = ex.text(fi); var rx = try ex.compile(pat, if (xy) 'x' else 'y'); var out: std.ArrayList(Range) = .empty; var op: ?usize = if (xy) null else r.q0; @@ -617,13 +951,13 @@ const Exec = struct { while (p <= r.q1) { var tr: Range = undefined; var sel_end: usize = undefined; - if (try ex.find(&rx, p, r.q1)) |m| { + if (try ex.find(&rx, txt, p, r.q1)) |m| { if (m.start == m.end) { if (op != null and m.start == op.?) { - p = stepRune(ex.text, p); + p = stepRune(txt, p); continue; } - p = stepRune(ex.text, m.end); + p = stepRune(txt, m.end); } else p = m.end; tr = if (xy) .{ .q0 = clip(m.start), .q1 = clip(m.end) } else .{ .q0 = clip(op.?), .q1 = clip(m.start) }; sel_end = m.end; @@ -642,12 +976,13 @@ const Exec = struct { /// ecmd.c:840, linelooper: each line in the range, the first from where /// the range starts, the last cut where it ends. - fn lines(ex: *Exec, r: Range) Failure![]Range { + fn lines(ex: *Exec, fi: usize, r: Range) Failure![]Range { + const txt = ex.text(fi); var out: std.ArrayList(Range) = .empty; var p: usize = r.q0; while (p < r.q1) { - const nl = std.mem.indexOfScalarPos(u8, ex.text, p, '\n'); - const e = @min(if (nl) |i| i + 1 else ex.text.len, r.q1); + const nl = std.mem.indexOfScalarPos(u8, txt, p, '\n'); + const e = @min(if (nl) |i| i + 1 else txt.len, r.q1); if (e <= p) break; try out.append(ex.arena, .{ .q0 = clip(p), .q1 = clip(e) }); p = e; @@ -656,7 +991,8 @@ const Exec = struct { } }; -/// The text after `ops`, which are in order and do not overlap. +/// The text after `ops`, which are in order and do not overlap (a job's +/// with its output in place). pub fn apply(gpa: std.mem.Allocator, text: []const u8, ops: []const Op) ![]u8 { var n: usize = text.len; for (ops) |o| n = n - (o.q1 - o.q0) + o.text.len; @@ -675,9 +1011,9 @@ pub fn apply(gpa: std.mem.Allocator, text: []const u8, ops: []const Op) ![]u8 { return out; } -/// Where `dot` lands after `ops`: each end moved by the changes before it; -/// an empty dot where a change starts selects what it put there, as -/// elogapply does (elog.c:283). +/// Where `dot` lands after `ops` (their texts final): each end moved by the +/// changes before it; an empty dot where a change starts selects what it +/// put there, as elogapply does (elog.c:283). pub fn moveDot(dot: Range, ops: []const Op) Range { var shift: i64 = 0; for (ops) |o| { @@ -702,11 +1038,11 @@ fn edited(text: []const u8, command: []const u8) ![]u8 { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); var why: Why = .{}; - const res = run(arena_state.allocator(), text, .{}, "t", command, &why) catch |err| { + const res = run(arena_state.allocator(), &.{.{ .name = "t", .text = text }}, 0, command, null, &why) catch |err| { if (err == error.Edit) std.debug.print("Edit: {s}\n", .{why.text()}); return err; }; - return apply(std.testing.allocator, text, res.ops); + return apply(std.testing.allocator, text, res.files[0].ops.items); } fn expectEdit(text: []const u8, command: []const u8, want: []const u8) !void { @@ -768,13 +1104,21 @@ test "an Edit that fails halfway changes nothing, and says why in acme's words" .{ "}", "right brace with no left brace" }, .{ ",x/foo/{", "unmatched `{'" }, .{ ",x/foo/{\nd", "unmatched `{'" }, - .{ "w /tmp/x", "w is a sam command pardes's Edit leaves out (it has no file or shell commands)" }, + .{ "B are jobs: a command per address, its output the change" { + var fx: Fixture = .init(); + defer fx.deinit(); + const res = try fx.edit(0, ",x/foo|bar/ | tr a-z A-Z\n$ > wc -c\n$ < date"); + try std.testing.expectEqual(@as(usize, 4), res.jobs.len); + try std.testing.expectEqual(@as(u8, '|'), res.jobs[0].c); + try std.testing.expectEqualStrings("tr a-z A-Z", res.jobs[0].command); + try std.testing.expectEqualStrings("foo", res.files[0].text[res.jobs[0].q0..res.jobs[0].q1]); + try std.testing.expectEqualStrings("bar", res.files[0].text[res.jobs[1].q0..res.jobs[1].q1]); + try std.testing.expectEqual(@as(u8, '>'), res.jobs[2].c); + try std.testing.expectEqual(@as(u8, '<'), res.jobs[3].c); + // `>` changes nothing; `|` and `<` hold their output's place. + const ops = res.files[0].ops.items; + try std.testing.expectEqual(@as(usize, 3), ops.len); + try std.testing.expectEqual(@as(?u32, 0), ops[0].job); + try std.testing.expectEqual(@as(?u32, 1), ops[1].job); + try std.testing.expectEqual(@as(?u32, 3), ops[2].job); + // ...out of order with the x before it: `1 <` reaches back. + try std.testing.expectError(error.Edit, fx.edit(0, ",x/bar/ | cat\n1 < date")); + try std.testing.expectEqualStrings("changes out of sequence", fx.why.text()); + // Across files with X: each job is its file's. + const x = try fx.edit(0, "X/\\.c$/ , | sort"); + try std.testing.expectEqual(@as(usize, 2), x.jobs.len); + try std.testing.expectEqual(@as(u32, 2), x.jobs[1].file); +} diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index 44cc1709..4fb024bb 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -18,11 +18,18 @@ pub const command_timeout_seconds: u64 = 10; pub const Input = struct { bytes: []const u8 }; /// Borrowed view exposed by the core while an effect is being drained. +/// An Edit's `<`, `|` and `>` (src/edit_cmd.zig) run a command of their own +/// per input, each in its file's directory: `commands` and `cwds`, when +/// given, name input i's, and `shell` (the session's Shell) runs them, +/// /bin/sh when it is empty. pub const Request = struct { id: u32, command: []const u8, cwd: []const u8, inputs: []const Input, + commands: []const []const u8 = &.{}, + cwds: []const []const u8 = &.{}, + shell: []const u8 = "", }; /// Worker-owned snapshot. `copy` is intentionally called synchronously while @@ -33,6 +40,10 @@ pub const Job = struct { command: []u8, cwd: []u8, inputs: [][]u8, + /// Input i's own command and directory, when the request gave them. + commands: [][]u8 = &.{}, + cwds: [][]u8 = &.{}, + shell: []u8 = &.{}, pub fn copy(gpa: std.mem.Allocator, request: Request) !*Job { const job = try gpa.create(Job); @@ -46,22 +57,45 @@ pub const Job = struct { errdefer gpa.free(job.command); job.cwd = try gpa.dupe(u8, filesystem.localPath(request.cwd) orelse request.cwd); errdefer gpa.free(job.cwd); - job.inputs = try gpa.alloc([]u8, request.inputs.len); - errdefer gpa.free(job.inputs); + job.inputs = try dupeAll(gpa, request.inputs.len, request.inputs, false); + errdefer freeAll(gpa, job.inputs); + job.commands = try dupeAll(gpa, request.commands.len, request.commands, false); + errdefer freeAll(gpa, job.commands); + job.cwds = try dupeAll(gpa, request.cwds.len, request.cwds, true); + errdefer freeAll(gpa, job.cwds); + job.shell = try gpa.dupe(u8, request.shell); + return job; + } + + /// Owned copies of `items` (Inputs or strings); a directory as the + /// host's own path, as `cwd` is. + fn dupeAll(gpa: std.mem.Allocator, n: usize, items: anytype, dirs: bool) ![][]u8 { + const out = try gpa.alloc([]u8, n); var made: usize = 0; - errdefer for (job.inputs[0..made]) |input| gpa.free(input); - for (request.inputs, 0..) |input, i| { - job.inputs[i] = try gpa.dupe(u8, input.bytes); + errdefer { + for (out[0..made]) |item| gpa.free(item); + gpa.free(out); + } + for (items) |item| { + const bytes: []const u8 = if (@TypeOf(item) == Input) item.bytes else item; + out[made] = try gpa.dupe(u8, if (dirs) filesystem.localPath(bytes) orelse bytes else bytes); made += 1; } - return job; + return out; + } + + fn freeAll(gpa: std.mem.Allocator, items: [][]u8) void { + for (items) |item| gpa.free(item); + gpa.free(items); } pub fn deinit(job: *Job, gpa: std.mem.Allocator) void { gpa.free(job.command); gpa.free(job.cwd); - for (job.inputs) |input| gpa.free(input); - gpa.free(job.inputs); + freeAll(gpa, job.inputs); + freeAll(gpa, job.commands); + freeAll(gpa, job.cwds); + gpa.free(job.shell); gpa.destroy(job); } }; @@ -193,6 +227,18 @@ pub fn runOne( command: []const u8, cwd: []const u8, input: []const u8, +) Outcome { + return runIn(gpa, io, "", command, cwd, input); +} + +/// `runOne` through `shell -c` (/bin/sh when it is empty). +pub fn runIn( + gpa: std.mem.Allocator, + io: std.Io, + shell: []const u8, + command: []const u8, + cwd: []const u8, + input: []const u8, ) Outcome { const fail = struct { fn k(kind: Failure.Kind) Outcome { @@ -200,7 +246,8 @@ pub fn runOne( } }; if (std.mem.indexOfScalar(u8, command, 0) != null or - std.mem.indexOfScalar(u8, cwd, 0) != null) return fail.k(.spawn); + std.mem.indexOfScalar(u8, cwd, 0) != null or + std.mem.indexOfScalar(u8, shell, 0) != null) return fail.k(.spawn); // std's spawn runs no code in the child: this thread's mask, which the // child inherits, is cleared across the fork (only the tty's SIGWINCH is @@ -211,7 +258,7 @@ pub fn runOne( std.posix.sigprocmask(std.posix.SIG.SETMASK, &none, &kept); defer std.posix.sigprocmask(std.posix.SIG.SETMASK, &kept, null); var child = std.process.spawn(io, .{ - .argv = &.{ "/bin/sh", "-c", command }, + .argv = &.{ if (shell.len == 0) "/bin/sh" else shell, "-c", command }, .cwd = if (cwd.len == 0) .inherit else .{ .path = cwd }, .stdin = .pipe, .stdout = .pipe, @@ -301,7 +348,9 @@ pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response { var made: usize = 0; var total: usize = 0; for (job.inputs, 0..) |input, i| { - const output = switch (runOne(gpa, io, job.command, job.cwd, input)) { + const command = if (job.commands.len > 0) job.commands[i] else job.command; + const cwd = if (job.cwds.len > 0) job.cwds[i] else job.cwd; + const output = switch (runIn(gpa, io, job.shell, command, cwd, input)) { .ok => |bytes| bytes, .failed => |f| { // WHICH selection, because with several cursors "it failed" is @@ -400,3 +449,22 @@ test "native pipe runner preserves stdin/stdout bytes and reports how it failed" .failed => return error.EarlyStdinCloseShouldNotFail, } } + +test "a request may give each input its own command and directory, run by the shell it names" { + const gpa = std.testing.allocator; + const job = try Job.copy(gpa, .{ + .id = 3, + .command = "", + .cwd = "", + .inputs = &.{ .{ .bytes = "" }, .{ .bytes = "abc" } }, + .commands = &.{ "pwd", "tr a-z A-Z" }, + .cwds = &.{ "/tmp", "/" }, + .shell = "/bin/sh", + }); + defer job.deinit(gpa); + var response = runJob(gpa, std.testing.io, job); + defer response.deinit(gpa); + try std.testing.expect(response.success); + try std.testing.expectEqualStrings("/tmp\n", response.outputs[0]); + try std.testing.expectEqualStrings("ABC", response.outputs[1]); +} -- cgit v1.3