summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 17:43:39 -0300
committerGabriel Schneider <[email protected]>2026-10-01 20:04:42 -0300
commit805b9f188768fee523d462411d846912e0877ff3 (patch)
treedc17de5cee6e4b554446052a29fd55e8a5285498
parent0728b937028418232a1da3d9922615a6f49680f2 (diff)
downloadpardes-805b9f188768fee523d462411d846912e0877ff3.tar.gz
pardes-805b9f188768fee523d462411d846912e0877ff3.zip
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 <[email protected]>
-rw-r--r--docs/typ/builtins-gui.json2
-rw-r--r--docs/typ/builtins-tty.json2
-rw-r--r--docs/typ/reference.typ43
-rw-r--r--src/9p_io.zig7
-rw-r--r--src/Pipe.zig63
-rw-r--r--src/builtins.zig35
-rw-r--r--src/edit_cmd.zig487
-rw-r--r--src/fs.zig3
-rw-r--r--src/ninep/ctl.zig7
-rw-r--r--src/pardes.zig50
-rw-r--r--src/sam_edit.zig705
-rw-r--r--src/selection_pipe.zig90
-rw-r--r--test/fs.py59
13 files changed, 1377 insertions, 176 deletions
diff --git a/docs/typ/builtins-gui.json b/docs/typ/builtins-gui.json
index 95eeaa07..b42f9c72 100644
--- a/docs/typ/builtins-gui.json
+++ b/docs/typ/builtins-gui.json
@@ -32,7 +32,7 @@
{"name": "DumpConfig", "leader": null, "also": [], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Open the settings in effect, each a word to click, and the startup file's path."},
{"name": "DumpDir", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": null, "doc": "The directory Dump writes to and a relative Restore reads from; bare, the default."},
{"name": "DumpThemes", "leader": null, "also": [], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Write the bundled themes into the config directory's themes, to edit."},
- {"name": "Edit", "leader": null, "also": [], "arg": true, "scope": "both", "setting": false, "choices": null, "doc": "acme's Edit: sam's command language over the pane's body, from a tag, a pane ctl or exec."},
+ {"name": "Edit", "leader": null, "also": [], "arg": true, "scope": "both", "setting": false, "choices": null, "doc": "acme's Edit: sam's command language over the open text panes, from a tag, a pane ctl or exec, starting in the pane's body."},
{"name": "EffectCode", "leader": null, "also": [], "arg": true, "scope": "root", "setting": false, "choices": null, "doc": "Open the source of a panel transition or post shader, by name."},
{"name": "Exec", "leader": null, "also": ["tab", "middle-click"], "arg": true, "scope": "pane", "setting": false, "choices": null, "doc": "Execute its argument as a middle click on it would, a builtin or a shell command."},
{"name": "Exit", "leader": "SPC q", "also": ["topbar"], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Quit the editor, refusing once while a pane holds unsaved text: an Exit with nothing edited since discards it."},
diff --git a/docs/typ/builtins-tty.json b/docs/typ/builtins-tty.json
index 887393ac..53bcf033 100644
--- a/docs/typ/builtins-tty.json
+++ b/docs/typ/builtins-tty.json
@@ -29,7 +29,7 @@
{"name": "DumpConfig", "leader": null, "also": [], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Open the settings in effect, each a word to click, and the startup file's path."},
{"name": "DumpDir", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": null, "doc": "The directory Dump writes to and a relative Restore reads from; bare, the default."},
{"name": "DumpThemes", "leader": null, "also": [], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Write the bundled themes into the config directory's themes, to edit."},
- {"name": "Edit", "leader": null, "also": [], "arg": true, "scope": "both", "setting": false, "choices": null, "doc": "acme's Edit: sam's command language over the pane's body, from a tag, a pane ctl or exec."},
+ {"name": "Edit", "leader": null, "also": [], "arg": true, "scope": "both", "setting": false, "choices": null, "doc": "acme's Edit: sam's command language over the open text panes, from a tag, a pane ctl or exec, starting in the pane's body."},
{"name": "EffectCode", "leader": null, "also": [], "arg": true, "scope": "root", "setting": false, "choices": null, "doc": "Open the source of a panel transition or post shader, by name."},
{"name": "Exec", "leader": null, "also": ["tab", "middle-click"], "arg": true, "scope": "pane", "setting": false, "choices": null, "doc": "Execute its argument as a middle click on it would, a builtin or a shell command."},
{"name": "Exit", "leader": "SPC q", "also": ["topbar"], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Quit the editor, refusing once while a pane holds unsaved text: an Exit with nothing edited since discards it."},
diff --git a/docs/typ/reference.typ b/docs/typ/reference.typ
index 5bcd8f6f..321860d6 100644
--- a/docs/typ/reference.typ
+++ b/docs/typ/reference.typ
@@ -98,8 +98,8 @@ own), never a C library string; a bad setting value is quoted, the value
itself (`bad value in control message; takes on, off "maybe"`); a ctl
refusal quotes the offending word alone (`wrong #args in control message "Newcol"`). Through 9ns the kernel sees an errno 9ns reads
from those words (cloud9's `fs.enameErrno`): the first of these that the
-words hold, case aside, wins, and anything else is EIO (`Modified`, an
-#word("Edit") command pardes leaves out):
+words hold, case aside, wins, and anything else is EIO (`Modified`, a
+sam command pardes's #word("Edit") leaves out, `B <cmd`):
#pairs(
[`control message`], [EINVAL],
@@ -683,15 +683,34 @@ ceiling:
== Edit <edit>
#word("Edit") `<sam commands>` on a pane's #file("ctl") or #file("exec") (or the
-root's, at the pane with the keyboard) runs acme's #word("Edit") on the body: addresses as
-above, commands `x y g v c a i d s p = m t u` and `{ }`. All changes are
-one undo step, applied only if every command succeeds; a failure changes
-nothing and fails the write with acme's words (`Edit: no substitution`).
-An `x` that finds nothing succeeds silently. `p` and `=` print to the
-directory's `+Errors`. Not there: `b B D e r w f X Y`, `< | >`,
-`\1`-`\9`. In `s`, `&` is the match (`\&` a literal); in `c`, `a`, `i` it
-is a literal. `y` yields the stretch before the first match too. Braces
-take a command a line, so a block goes on one open, in one write or
+root's, at the pane with the keyboard) runs acme's #word("Edit") over the open text
+panes, from that pane's body. Addresses are as above, and `"re"` is the one
+open file whose line matches. Commands:
+
+- `x y g v c a i d s p = m t u` and `{ }`.
+- `X`/`Y` run a command in each open file whose line (` +. /path`, `'` when
+ edited) matches, or does not: `X/'/w` writes every edited file.
+- `b` makes a file current, `B` opens files, `D` closes panes (an edited one
+ on the second asking, as #word("Del")).
+- `e` reads a file over the whole text and names the pane after it, `r`
+ over dot. `w` writes all of it, or the address, to its file or a name;
+ all of it to its own name leaves it clean. `f` names the pane and prints
+ its line.
+- `<cmd` replaces dot with what `cmd` writes, `|cmd` pipes dot through it,
+ `>cmd` sends dot to it and prints what it writes.
+
+Each command runs in its file's directory through #word("Shell"), off the
+loop, so the editor goes on; the write that ran the Edit is answered when
+they are done, and a line after it in the same write is refused. A command
+that fails (an exit status, 10 seconds, 1 MiB of output) fails the Edit, its
+stderr in `+Errors`. Each file's changes are one undo step, applied only if
+every command succeeds; a failure changes nothing and fails the write with
+acme's words (`Edit: no substitution`), as does a file edited while the
+commands ran. An `x` that finds nothing succeeds silently. `p`, `=` and `>`
+print to the directory's `+Errors`. Not there: `B <cmd`, `D <cmd`, the `'`
+address, `\1`-`\9`. In `s`, `&` is the match (`\&` a literal); in `c`, `a`,
+`i` it is a literal. `y` yields the stretch before the first match too.
+Braces take a command a line, so a block goes on one open, in one write or
several:
#cmd("printf 'Edit ,x/foo/{\\ni/</\\na/>/\\n}\\n' > $p/ctl")
@@ -1040,7 +1059,7 @@ with Client(sys.argv[1]) as c: # a socket path, or (ip, port)
[`Snarf`, `Cut`, `Paste`], [refused: the chords, #key("y"), #key("p")],
[`Font`, `Send`, `ID`], [#word("Font"); #word("Repl") and #word("Exec"); `$winid`],
[acme's other builtins], [refused: `Sort`, `Local`, `Abort` (plan9port's debugging word)],
- [Edit], [no `< | >`, `X`, `Y`, `b B D e r w f`, no `\1`-`\9`; an alternation takes the first branch that matches, not the longest (#doc("fs", section: "edit"))],
+ [Edit], [every acme command but `B <cmd`, `D <cmd` and the `'` address; no `\1`-`\9`, and an alternation takes the first branch that matches, not the longest; a failing `< | >` command changes nothing, where acme puts in what it wrote (#doc("fs", section: "edit"))],
[`win`], [#word("Tty") is a VT terminal pane; #file("pty/run") runs a line at its prompt and answers `exit N` and the output; with #word("Repl") bound, #word("Exec") on a source pane types the text into it],
[a directory window], [as acme's: a text pane named `dir/` (#file("index") kind `text`, #file("ctl") isdir 1), its entries in columns sorted bytewise, dotfiles shown, a directory's marked `/`; blanks pad the columns where acme's tabs do; a #word("Look") at an entry is from that directory, `Get` or a look at it again reads it again, and an edit is kept until then rather than lost to a resize; never dirty, #word("Save") refuses it; #word("DirLook") `terminal` types `ls` into a terminal there instead],
[the plumber], [none: #word("Look") on an `http://` or `https://` URL runs `xdg-open` (`open` on macOS); files, addresses and directories follow built-in rules],
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 <serial> <name>` 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 <cmd` and `D <cmd` (a file list from a
+//! command), and `\1`..`\9` in `s` and sam's longest of alternatives (mvzr
+//! keeps no submatches and takes the first alternative that matches). `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.
+//! A command that fails (`|`, `<`, `>`) 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 <command is not supported: give the names", .{});
+ if (list.len == 0) return fail(ex.why, e_noname, .{});
+ const w = ex.world orelse return fail(ex.why, "B: no panes to open here", .{});
+ var names = std.mem.tokenizeAny(u8, list, " \t");
+ while (names.next()) |name| {
+ const path = try ex.pathIn(fi, name);
+ const opened = w.open(w.ctx, path, ex.why) orelse return error.Edit;
+ if (for (ex.files.items) |f| {
+ if (f.id == opened.id and !f.close) break true;
+ } else false) continue;
+ try ex.files.append(ex.arena, opened);
+ }
+ return r;
+ },
+ 'D' => {
+ const list = std.mem.trim(u8, c.text, " \t");
+ if (list.len > 0 and list[0] == '<') return fail(ex.why, "D <command is not supported: give the names", .{});
+ if (list.len == 0) {
+ try ex.closeFile(fi);
+ return r;
+ }
+ var names = std.mem.tokenizeAny(u8, list, " \t");
+ while (names.next()) |name| {
+ const path = try ex.pathIn(fi, name);
+ try ex.closeFile(ex.lookFile(path) orelse return fail(ex.why, "no such file {s}", .{path}));
+ }
+ return r;
+ },
+ 'e', 'r' => {
+ // 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 <ls", "B <command is not supported: give the names" },
+ .{ "1d\nD", "D: t has changes pending in this Edit" },
+ .{ "X X p", "can't nest X command" },
+ .{ "3 b t", "command takes no address" },
+ .{ ",|", "no command specified for |" },
+ .{ "b nowhere", "no such file \"nowhere\"" },
+ .{ "1d\nw /tmp/x", "can't write file with pending modifications" },
+ .{ "e /tmp/x", "can't open /tmp/x: no files here" },
+ .{ "\"zz\" p", "no file matches \"zz\"" },
.{ ",s/(a)/\\1/", "no \\1: mvzr keeps no submatches" },
.{ "1 m 1,2", "move overlaps itself" },
.{ ",x/(^|\\n)foo/d", "bad regular expression: in a pattern with \\n, ^ can only come first and $ only just before a \\n" },
}) |c| {
var why: Why = .{};
- try std.testing.expectError(error.Edit, run(arena_state.allocator(), "foo a\nfoo\n", .{}, "t", c[0], &why));
+ try std.testing.expectError(error.Edit, run(arena_state.allocator(), &.{.{ .name = "t", .text = "foo a\nfoo\n" }}, 0, c[0], null, &why));
try std.testing.expectEqualStrings(c[1], why.text());
}
}
@@ -808,3 +1152,160 @@ test "x, y and s match whole runes: a byte pattern never splits one" {
try expectEdit("a\x81b", ",x/./a/|/", "a|\x81|b|");
try expectEdit("\u{e9}", ",s/$/!/", "\u{e9}!");
}
+
+/// Three files and a +Errors, the way the editor hands them over, and a
+/// world whose disk holds /src/new.c and /src/data.
+const Fixture = struct {
+ arena_state: std.heap.ArenaAllocator,
+ why: Why = .{},
+
+ const files = [_]File{
+ .{ .name = "/src/a.c", .text = "int foo;\nint bar;\n", .id = 0 },
+ .{ .name = "/src/b.h", .text = "foo bar foo\n", .id = 1, .dirty = true },
+ .{ .name = "/src/c.c", .text = "bar\n", .id = 2 },
+ .{ .name = "/src/+Errors", .text = "foo\n", .id = 3, .scratch = true },
+ };
+
+ fn open(_: *anyopaque, path: []const u8, why: *Why) ?File {
+ if (std.mem.eql(u8, path, "/src/new.c")) return .{ .name = "/src/new.c", .text = "new foo\n", .id = 9 };
+ why.len = if (std.fmt.bufPrint(&why.buf, "B: cannot open {s}", .{path})) |said| said.len else |_| 0;
+ return null;
+ }
+
+ fn read(_: *anyopaque, arena: std.mem.Allocator, path: []const u8, why: *Why) ?[]const u8 {
+ if (std.mem.eql(u8, path, "/src/data")) return arena.dupe(u8, "DATA\n") catch null;
+ why.len = if (std.fmt.bufPrint(&why.buf, "can't open {s}: file does not exist", .{path})) |said| said.len else |_| 0;
+ return null;
+ }
+
+ fn init() Fixture {
+ return .{ .arena_state = .init(std.testing.allocator) };
+ }
+
+ fn deinit(fx: *Fixture) void {
+ fx.arena_state.deinit();
+ }
+
+ fn edit(fx: *Fixture, from: usize, command: []const u8) !Result {
+ var ctx: u8 = 0;
+ const world: World = .{ .ctx = &ctx, .open = open, .read = read };
+ return run(fx.arena_state.allocator(), &files, from, command, world, &fx.why);
+ }
+
+ /// File `i`'s text after the Edit.
+ fn after(fx: *Fixture, res: Result, i: usize) ![]const u8 {
+ return apply(fx.arena_state.allocator(), res.files[i].text, res.files[i].ops.items);
+ }
+};
+
+test "X and Y run a command in each open file whose line matches, or does not" {
+ var fx: Fixture = .init();
+ defer fx.deinit();
+ // ecmd.c filelooper: each file from its own dot, the current one too.
+ const x = try fx.edit(0, "X/\\.c$/ ,s/foo/FOO/g");
+ try std.testing.expectEqualStrings("int FOO;\nint bar;\n", try fx.after(x, 0));
+ try std.testing.expectEqual(@as(usize, 0), x.files[1].ops.items.len);
+ try std.testing.expectEqual(@as(usize, 0), x.files[2].ops.items.len); // no foo: s in a loop is quiet
+ const y = try fx.edit(0, "Y/\\.c$/ ,s/foo/FOO/g");
+ try std.testing.expectEqualStrings("FOO bar FOO\n", try fx.after(y, 1));
+ try std.testing.expectEqualStrings("FOO\n", try fx.after(y, 3));
+ try std.testing.expectEqual(@as(usize, 0), y.files[0].ops.items.len);
+ // X alone is every file with a name, f its default: acme's file lines,
+ // `'` for the edited one and `.` for the current.
+ const all = try fx.edit(2, "X");
+ try std.testing.expectEqualStrings(" + /src/a.c\n'+ /src/b.h\n +. /src/c.c\n", all.printed);
+ // The line is what is matched: X/'/ is every edited file.
+ const dirty = try fx.edit(0, "X/'/ =");
+ try std.testing.expectEqualStrings("/src/b.h:1\n", dirty.printed);
+}
+
+test "b moves the current file, \"re\" addresses another, and f names one" {
+ var fx: Fixture = .init();
+ defer fx.deinit();
+ const b = try fx.edit(0, "b /src/c.c\n,c/C/");
+ try std.testing.expectEqualStrings(" +. /src/c.c\n", b.printed);
+ try std.testing.expectEqualStrings("C", try fx.after(b, 2));
+ try std.testing.expectEqual(@as(usize, 0), b.files[0].ops.items.len);
+ try std.testing.expectEqual(@as(usize, 2), b.cur);
+ // A name in the current file's directory will do.
+ try std.testing.expectEqual(@as(usize, 1), (try fx.edit(0, "b b.h")).cur);
+ // `"re"`: that file's line, the command in it, the current file kept.
+ const q = try fx.edit(0, "\"b\\.h\" 1 s/bar/BAR/");
+ try std.testing.expectEqualStrings("foo BAR foo\n", try fx.after(q, 1));
+ try std.testing.expectEqual(@as(usize, 0), q.cur);
+ // Only `;` carries the file to the right of a `,`, as in acme.
+ try std.testing.expectError(error.Edit, fx.edit(0, "\"b\\.h\" ,s/bar/BAR/"));
+ try std.testing.expectEqualStrings("addresses in different files", fx.why.text());
+ try std.testing.expectEqualStrings("FOO", (try fx.after(try fx.edit(0, "\"b\\.h\"0;$ s/foo bar foo/FOO/"), 1))[0..3]);
+ try std.testing.expectError(error.Edit, fx.edit(0, "\"\\.c\" p"));
+ try std.testing.expectEqualStrings("too many files match \"\\.c\"", fx.why.text());
+ const f = try fx.edit(0, "f other.c");
+ try std.testing.expectEqualStrings(" +. /src/other.c\n", f.printed);
+ try std.testing.expect(f.files[0].renamed);
+}
+
+test "e and r read a file into the text, w writes it, D and B close and open panes" {
+ var fx: Fixture = .init();
+ defer fx.deinit();
+ // r replaces dot; e the whole text, and names the file after it.
+ const r = try fx.edit(0, "2 r data");
+ try std.testing.expectEqualStrings("int foo;\nDATA\n", try fx.after(r, 0));
+ try std.testing.expect(!r.files[0].renamed);
+ const e = try fx.edit(0, "e /src/data");
+ try std.testing.expectEqualStrings("DATA\n", try fx.after(e, 0));
+ try std.testing.expectEqualStrings("/src/data", e.files[0].name);
+ try std.testing.expect(!e.files[0].clean);
+ // e of an edited file is refused, as acme's winclean refuses it.
+ try std.testing.expectError(error.Edit, fx.edit(1, "e"));
+ try std.testing.expectEqualStrings("/src/b.h modified", fx.why.text());
+ try std.testing.expectError(error.Edit, fx.edit(0, "r nothing"));
+ try std.testing.expectEqualStrings("can't open /src/nothing: file does not exist", fx.why.text());
+ // w: all of it by default, to its own name; an address and a name.
+ const w = try fx.edit(0, "w\n2 w part");
+ try std.testing.expectEqual(@as(usize, 2), w.writes.len);
+ try std.testing.expectEqualStrings("/src/a.c", w.writes[0].path);
+ try std.testing.expect(w.writes[0].whole);
+ try std.testing.expectEqualStrings("/src/part", w.writes[1].path);
+ try std.testing.expectEqualStrings("int bar;\n", w.writes[1].bytes);
+ try std.testing.expect(!w.writes[1].whole);
+ try std.testing.expectError(error.Edit, fx.edit(3, "w"));
+ try std.testing.expectEqualStrings("no name specified for 'w' command", fx.why.text());
+ // D marks the panes to close once the Edit is done; a closed one takes
+ // no more commands.
+ const d = try fx.edit(0, "D c.c b.h");
+ try std.testing.expect(d.files[2].close and d.files[1].close and !d.files[0].close);
+ try std.testing.expectError(error.Edit, fx.edit(0, "D\n,d"));
+ try std.testing.expectEqualStrings("no current window", fx.why.text());
+ // B opens a pane, which the rest of the Edit can then use.
+ const b = try fx.edit(0, "B new.c\nb /src/new.c\n,s/new/old/");
+ try std.testing.expectEqual(@as(usize, 5), b.files.len);
+ try std.testing.expectEqualStrings("old foo\n", try fx.after(b, 4));
+ try std.testing.expectError(error.Edit, fx.edit(0, "B gone.c"));
+ try std.testing.expectEqualStrings("B: cannot open /src/gone.c", fx.why.text());
+}
+
+test "<, | and > 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);
}
};
@@ -194,13 +228,26 @@ pub fn runOne(
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 {
return .{ .failed = .{ .kind = kind } };
}
};
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]);
+}
diff --git a/test/fs.py b/test/fs.py
index 65b354d9..1303db0d 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -1008,9 +1008,68 @@ def cut_writes(binary):
assert (screen['cols'], screen['rows']) == (132, 40), (screen['cols'], screen['rows'])
+def edit_commands(binary):
+ """Edit's `< | >` run off the loop and answer the write that ran them
+ once applied; a failing one changes nothing and fails the write in its
+ words; X edits every open file it matches; w writes. The editor answers
+ other requests while a slow command runs."""
+ with tempfile.TemporaryDirectory(prefix='pardes-edit-') as directory:
+ root = Path(directory)
+ (root / 'a.txt').write_bytes(b'pear\napple\nfig\n')
+ (root / 'b.txt').write_bytes(b'apple pie\n')
+ with session(binary, root, 'edit') as (client, address):
+ a = look(client, str(root / 'a.txt'))
+ b = look(client, str(root / 'b.txt'))
+ # The write returns with the text sorted, through the session's
+ # shell in the file's directory.
+ client.write(f'/pane/{a}/ctl', b'Edit , | sort\n')
+ assert client.read(f'/pane/{a}/body') == b'apple\nfig\npear\n'
+ client.write(f'/pane/{a}/ctl', b'Edit $ < pwd\n')
+ assert client.read(f'/pane/{a}/body') == f'apple\nfig\npear\n{root}\n'.encode()
+ client.write(f'/pane/{a}/ctl', b'Undo\n')
+ assert client.read(f'/pane/{a}/body') == b'apple\nfig\npear\n'
+ # A per-match command, one undo step.
+ client.write(f'/pane/{a}/ctl', b'Edit ,x/[a-z]+/ | tr a-z A-Z\n')
+ assert client.read(f'/pane/{a}/body') == b'APPLE\nFIG\nPEAR\n'
+ # A failing command: nothing changed, the write fails in words,
+ # its stderr in +Errors.
+ try:
+ client.write(f'/pane/{a}/ctl', b'Edit , | echo nope >&2; exit 4\n')
+ raise AssertionError('a failing Edit command was taken')
+ except OSError as why:
+ assert 'exit status 4' in str(why), why
+ assert client.read(f'/pane/{a}/body') == b'APPLE\nFIG\nPEAR\n'
+ index = client.read('/index').decode()
+ errors = [line.split()[0] for line in index.splitlines() if line.endswith(('+Errors', f'{root}/+Errors')) or '+Errors' in line]
+ assert errors and any(b'nope' in client.read(f'/pane/{e}/body') for e in errors), index
+ # While a slow command runs, the editor answers.
+ done = threading.Event()
+
+ def slow():
+ with Client(address) as other:
+ other.write(f'/pane/{b}/ctl', b'Edit , | sleep 1; cat\n')
+ done.set()
+ worker = threading.Thread(target=slow)
+ worker.start()
+ time.sleep(.2)
+ began = time.monotonic()
+ client.read('/index')
+ assert time.monotonic() - began < .5 and not done.is_set(), 'the editor waited on the command'
+ worker.join(timeout=15)
+ assert done.is_set()
+ # X: each open file whose line matches; w writes it.
+ client.write(f'/pane/{a}/ctl', b'Edit X/b\\.txt$/ ,s/apple/APPLE/\n')
+ assert client.read(f'/pane/{b}/body') == b'APPLE pie\n'
+ client.write(f'/pane/{b}/ctl', b'Edit w\n')
+ assert (root / 'b.txt').read_bytes() == b'APPLE pie\n'
+ client.write(f'/pane/{a}/ctl', b'Edit 1 w first.txt\n')
+ assert (root / 'first.txt').read_bytes() == b'APPLE\n'
+
+
def test(binary, quic=False):
started = time.monotonic()
cut_writes(binary)
+ edit_commands(binary)
odd_paths(binary)
delcol_last_keeps_window(binary)
run_file(binary)