diff options
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 8 | ||||
| -rw-r--r-- | docs/fs.md | 5 | ||||
| -rw-r--r-- | docs/tags.md | 11 | ||||
| -rw-r--r-- | src/builtins.zig | 31 | ||||
| -rw-r--r-- | src/config.zig | 1 | ||||
| -rw-r--r-- | src/fs-help.txt | 2 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 17 | ||||
| -rw-r--r-- | src/pardes.zig | 1 | ||||
| -rw-r--r-- | src/sam_edit.zig | 667 | ||||
| -rw-r--r-- | test/snapshots/builtins.snap | 1 |
10 files changed, 743 insertions, 1 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 55df3091..757ce64a 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -150,6 +150,14 @@ then `\r`: sent line by line, a blank line ends a Python block, and Python For a file or scratch pane, with `pane=$m/pane/<serial>`: +Rename everywhere, or any sam edit, is one write: `echo 'Edit ,x/foo/c/bar/' +> $pane/ctl`. `Edit` takes sam's command language (acme's): addresses, `x y g +v c a i d s p = m t u` and `{ }` (commands in braces one to a line, so from +exec or a tag, not a one-line ctl write). Its changes are one undo step, and +one that fails changes nothing and fails the write with acme's words (`Edit: +no substitution`), logged as `err`. `p` and `=` print to the directory's +`+Errors`. Not there: `b B D e r w f X Y`, `< | >`, and `\1`-`\9` in `s`. + | Operation | Shell | Python client | |---|---|---| | Read text | `cat $pane/body` | `client.read(pane + '/body')` | @@ -364,6 +364,11 @@ scrolls the pane into view, and `limit` bounds a search and reads empty until it is set. Truncating `dot` empties it, truncating `limit` lifts it, and truncating `addr` leaves it as it is (below). +A rename everywhere, or any other sam edit, is one write to the pane's +`ctl`: `Edit ,x/foo/c/bar/` runs acme's Edit (docs/tags.md) on the body as +one undo step; a failure fails the write with acme's words and changes +nothing. + `line:col` is a pardes extension to sam's addresses, the spelling Look takes in `file:12:5`: `12:5` is the point at line 12, column 5, and it composes like any simple address (`12:5,14:1`, `12:5+#3`). The column is diff --git a/docs/tags.md b/docs/tags.md index de540c91..fdea1472 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -43,6 +43,17 @@ Its text and terminal process are kept; the command stays available in the visible tag. Every default pane tag includes `Collapse`, including file, terminal, image, and PDF panes. +`Edit` is acme's: the rest of the line is sam's command language, run on +the pane's body (`Edit ,x/foo/c/bar/` renames every `foo`), from a tag, a +pane's `ctl` or `exec`. Addresses are the `addr` file's, `line:col` +included; the commands are `x y g v c a i d s p = m t`, `u` alone, and `{ }` +with a command per line. All its changes are one undo step, applied only +if every command ran; an error says why in acme's words and changes +nothing. `p` and `=` print to the directory's `+Errors`. Left out: the file +commands `b B D e r w f X Y`, the pipes `< | >`, and `\1`-`\9` in `s`, +since mvzr keeps no submatches; acme applies changes that come out of +sequence with a warning, pardes refuses the Edit. + `Undo` and `Redo` are acme's: typed or clicked in a pane's tag, or written to its `ctl`, they step the body back and forward through its edits, as the `u` and `U` keys do. They are not in the default tags. diff --git a/src/builtins.zig b/src/builtins.zig index ef425c91..72df0271 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -883,6 +883,37 @@ 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. +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); + 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); + } +}; + /// acme's Undo and Redo (exec.c `undo`): step the pane's body back or /// forward through its edits, from a tag, a pane ctl or exec. pub const Undo = struct { diff --git a/src/config.zig b/src/config.zig index 416754a6..2eaaa505 100644 --- a/src/config.zig +++ b/src/config.zig @@ -50,6 +50,7 @@ pub const leader_path = paths: { .Togglettymode = null, .Collapse = null, .Undo = null, + .Edit = null, .Redo = null, .Repl = null, .Kill = null, diff --git a/src/fs-help.txt b/src/fs-help.txt index 324b992e..2b42be1f 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -24,7 +24,7 @@ Below, $m is the mount: $NINE_MOUNT/pardes/<pid, or NAME of --detach=NAME>; PARD printf 'text\n' >> $m/pane/$n/body append to a pane (> replaces it all) echo notes.txt > $m/pane/$n/name; echo Save > $m/pane/$n/ctl rename, then save it echo 'Msg hello' > $m/exec show text in the editor - echo '/^fn/' > $m/pane/$n/addr; printf NEW > $m/pane/$n/data replace what addr matched + echo 'Edit ,x/foo/c/bar/' > $m/pane/$n/ctl rename every foo, one undo step (sam's commands) cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more cat $m/pane/$n/event hold it open to own that pane's clicks diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index d7d50cc6..18068ab1 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -770,6 +770,23 @@ test "a second lock is refused until the holder unlocks or closes, and binds nob for (p.fs.opens) |o| try testing.expect(o.node == 0); } +test "Edit through a pane's ctl renames every foo in one undo step, and a failed one changes nothing" { + const p = try withFile(testing.allocator, "foo x foo y foo\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const body = Node.of(serial, .body); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "Edit ,x/foo/c/foobar/\n").reply.status); + try testing.expectEqualStrings("foobar x foobar y foobar\n", rd(p, body, 0, 64).bytes); + const failed = wr(p, ctl_node, "Edit ,s/nothing/x/\n"); + try testing.expectEqual(E.IO, failed.errno()); + try testing.expectStringStartsWith(failed.reply.ename, "Edit: no substitution"); + try testing.expectEqualStrings("foobar x foobar y foobar\n", rd(p, body, 0, 64).bytes); + // The whole rename is one undo step. + try testing.expectEqual(Status.ok, wr(p, ctl_node, "Undo").reply.status); + try testing.expectEqualStrings("foo x foo y foo\n", rd(p, body, 0, 64).bytes); +} + test "dirty goes back to 0 when the text is the saved text again" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); diff --git a/src/pardes.zig b/src/pardes.zig index 581f616e..00843b78 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -452,6 +452,7 @@ test { _ = @import("tagline.zig"); _ = @import("exec.zig"); _ = @import("look.zig"); + _ = @import("sam_edit.zig"); _ = @import("mouse.zig"); _ = @import("normal.zig"); _ = @import("regexp.zig"); diff --git a/src/sam_edit.zig b/src/sam_edit.zig new file mode 100644 index 00000000..c08c6cdd --- /dev/null +++ b/src/sam_edit.zig @@ -0,0 +1,667 @@ +//! 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. +//! +//! 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. +const std = @import("std"); +const regexp = @import("regexp.zig"); +const addr_lang = @import("ninep/addr.zig"); + +pub const Range = addr_lang.Range; + +pub const Op = struct { q0: u32, q1: u32, text: []const u8 }; + +/// What an Edit came to: the changes in order, the dot it leaves (in the +/// text before them), and what `p` and `=` printed. +pub const Result = struct { + ops: []const Op, + dot: Range, + printed: []const u8, + /// A bare `u`/`u-3`: undo (positive) or redo (negative) that many. + undo: i32 = 0, +}; + +pub const Failure = error{ Edit, OutOfMemory }; + +/// Why an Edit failed, in acme's words, when `run` answered error.Edit. +pub const Why = struct { + buf: [200]u8 = undefined, + len: usize = 0, + pub fn text(w: *const Why) []const u8 { + return w.buf[0..w.len]; + } +}; + +const Cmd = struct { + c: u8, + addr: ?[]const u8 = null, + re: ?[]const u8 = null, + text: []const u8 = "", + sub: ?*Cmd = null, + next: ?*Cmd = null, + num: i32 = 0, + global: bool = false, + to: ?[]const u8 = null, +}; + +/// Runs `command` over `text` from `dot`, the file named `name` (for `=`). +/// 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 { + var src = try arena.alloc(u8, command.len + 1); + @memcpy(src[0..command.len], command); + src[command.len] = '\n'; + if (command.len > 0 and command[command.len - 1] == '\n') src = src[0..command.len]; + var ps: Parser = .{ .arena = arena, .src = src, .why = why }; + var first: ?*Cmd = null; + var last: ?*Cmd = null; + while (try ps.parse(0)) |c| { + if (last) |l| l.next = c else first = c; + last = c; + } + const cmds = first orelse return .{ .ops = &.{}, .dot = dot, .printed = "" }; + // 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 }; + } + var ex: Exec = .{ .arena = arena, .text = text, .name = name, .why = why }; + var d = dot; + 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 }; +} + +fn fail(why: *Why, comptime fmt: []const u8, args: anytype) Failure { + why.len = if (std.fmt.bufPrint(&why.buf, fmt, args)) |s| s.len else |_| 0; + return error.Edit; +} + +// ---- parsing: edit.c ---- + +const Parser = struct { + arena: std.mem.Allocator, + src: []const u8, + i: usize = 0, + /// The last pattern given, for an empty one (edit.c:562, getregexp). + lastpat: []const u8 = "", + why: *Why, + + fn getch(ps: *Parser) ?u8 { + if (ps.i >= ps.src.len) return null; + ps.i += 1; + return ps.src[ps.i - 1]; + } + + fn nextc(ps: *const Parser) ?u8 { + return if (ps.i < ps.src.len) ps.src[ps.i] else null; + } + + fn skipbl(ps: *Parser) ?u8 { + while (ps.nextc()) |c| { + if (c != ' ' and c != '\t') return c; + ps.i += 1; + } + return null; + } + + fn atnl(ps: *Parser) Failure!void { + _ = ps.skipbl(); + const c = ps.getch() orelse '\n'; + if (c != '\n') return fail(ps.why, "newline expected (saw {c})", .{c}); + } + + fn okdelim(ps: *Parser, c: u8) Failure!void { + if (c == '\\' or std.ascii.isAlphanumeric(c)) return fail(ps.why, "bad delimiter {c}", .{c}); + } + + /// edit.c:216: a count, 1 when there is none; `-` allowed when signok > 1. + fn getnum(ps: *Parser, signok: u8) i32 { + var sign: i32 = 1; + if (signok > 1 and ps.nextc() == '-') { + sign = -1; + ps.i += 1; + } + const c = ps.nextc() orelse return sign; + if (c < '0' or c > '9') return sign; + var n: i32 = 0; + while (ps.nextc()) |d| { + if (d < '0' or d > '9') break; + n = n *| 10 +| (d - '0'); + ps.i += 1; + } + return sign * n; + } + + /// 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 { + var out: std.ArrayList(u8) = .empty; + while (true) { + const c = ps.skipbl() orelse break; + switch (c) { + '0'...'9', '#', '.', '$', '+', '-', ',', ';' => { + ps.i += 1; + try out.append(ps.arena, c); + // 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') + { + ps.i += 1; + try out.append(ps.arena, ':'); + } + }, + '/', '?' => { + ps.i += 1; + try out.append(ps.arena, c); + while (ps.nextc()) |d| { + if (d == '\n') break; + ps.i += 1; + try out.append(ps.arena, d); + if (d == '\\') { + if (ps.nextc()) |e| if (e != '\n') { + ps.i += 1; + try out.append(ps.arena, e); + }; + continue; + } + if (d == c) break; + } + }, + '\'' => return fail(ps.why, "can't handle '", .{}), + '"' => return fail(ps.why, "file addresses are not supported", .{}), + else => break, + } + } + return if (out.items.len == 0) null else out.items; + } + + /// edit.c:562: up to the delimiter; `\delim` is the delimiter, `\\` stays. + fn getregexp(ps: *Parser, delim: u8) Failure![]const u8 { + var buf: std.ArrayList(u8) = .empty; + while (true) { + var c = ps.getch() orelse break; + if (c == '\\') { + if (ps.nextc() == delim) { + c = ps.getch().?; + } else if (ps.nextc() == '\\') { + try buf.append(ps.arena, c); + c = ps.getch().?; + } + } else if (c == delim or c == '\n') { + if (c == '\n') ps.i -= 1; + break; + } + try buf.append(ps.arena, c); + } + if (buf.items.len > 0) ps.lastpat = buf.items; + if (ps.lastpat.len == 0) return fail(ps.why, "no regular expression defined", .{}); + return ps.lastpat; + } + + /// edit.c:388: `\n` is a newline; for `s` every other escape is kept + /// for it to read (`\&`, `\\`). + fn getrhs(ps: *Parser, delim: u8, cmd: u8) Failure![]const u8 { + var s: std.ArrayList(u8) = .empty; + while (ps.getch()) |c0| { + var c = c0; + if (c == delim or c == '\n') { + ps.i -= 1; + break; + } + if (c == '\\') { + c = ps.getch() orelse return fail(ps.why, "bad right hand side", .{}); + if (c == '\n') { + ps.i -= 1; + c = '\\'; + } else if (c == 'n') { + c = '\n'; + } else if (c != delim and (cmd == 's' or c != '\\')) { + try s.append(ps.arena, '\\'); + } + } + try s.append(ps.arena, c); + } + return s.items; + } + + /// edit.c:425: `/text/` on the line, or lines up to one that is `.`. + fn collecttext(ps: *Parser) Failure![]const u8 { + if (ps.skipbl() == '\n') { + ps.i += 1; + var s: std.ArrayList(u8) = .empty; + while (ps.i < ps.src.len) { + const end = std.mem.indexOfScalarPos(u8, ps.src, ps.i, '\n') orelse ps.src.len; + const line = ps.src[ps.i..end]; + ps.i = @min(end + 1, ps.src.len); + if (std.mem.eql(u8, line, ".")) return s.items; + try s.appendSlice(ps.arena, line); + try s.append(ps.arena, '\n'); + } + return s.items; + } + const delim = ps.getch() orelse return ""; + try ps.okdelim(delim); + const s = try ps.getrhs(delim, 'a'); + if (ps.nextc() == delim) ps.i += 1; + try ps.atnl(); + return s; + } + + /// edit.c:467, parsecmd. + fn parse(ps: *Parser, nest: u32) Failure!?*Cmd { + const a = try ps.address(); + _ = ps.skipbl() orelse return null; + const c = ps.getch() orelse return null; + const cmd = try ps.arena.create(Cmd); + cmd.* = .{ .c = c, .addr = a }; + switch (c) { + '\n' => {}, + 'a', 'c', 'i' => cmd.text = try ps.collecttext(), + 'd', 'p' => try ps.atnl(), + '=' => { + _ = ps.skipbl(); + const start = ps.i; + while (ps.nextc()) |d| : (ps.i += 1) if (d == '\n') break; + cmd.text = std.mem.trimEnd(u8, ps.src[start..ps.i], " \t"); + ps.i = @min(ps.i + 1, ps.src.len); + if (cmd.text.len != 0 and !std.mem.eql(u8, cmd.text, "#")) return fail(ps.why, "newline expected", .{}); + }, + 's' => { + cmd.num = ps.getnum(1); + _ = ps.skipbl(); + const delim = ps.getch() orelse '\n'; + if (delim == '\n') return fail(ps.why, "no address", .{}); + try ps.okdelim(delim); + cmd.re = try ps.getregexp(delim); + cmd.text = try ps.getrhs(delim, 's'); + if (ps.nextc() == delim) { + ps.i += 1; + if (ps.nextc() == 'g') { + ps.i += 1; + cmd.global = true; + } + } + try ps.atnl(); + }, + 'x', 'y', 'g', 'v' => { + // x without a pattern is every line (ecmd.c:568, linelooper). + const n = ps.nextc(); + if (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", .{}); + try ps.okdelim(delim); + cmd.re = try ps.getregexp(delim); + } + if (ps.skipbl() == '\n') { + ps.i += 1; + const p = try ps.arena.create(Cmd); + p.* = .{ .c = '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", .{}); + try ps.atnl(); + }, + 'u' => { + if (a != null) return fail(ps.why, "command takes no address", .{}); + cmd.num = ps.getnum(2); + try ps.atnl(); + }, + '{' => { + var last: ?*Cmd = null; + while (true) { + if (ps.skipbl() == '\n') ps.i += 1; + const next = (try ps.parse(nest + 1)) orelse break; + if (last) |l| l.next = next else cmd.sub = next; + last = next; + } + }, + '}' => { + try ps.atnl(); + 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 not supported in pardes", .{c}), + else => return fail(ps.why, "unknown command {c}", .{c}), + } + return cmd; + } +}; + +// ---- running: ecmd.c, logging: elog.c ---- + +const Exec = struct { + arena: std.mem.Allocator, + text: []const u8, + name: []const u8, + why: *Why, + ops: std.ArrayList(Op) = .empty, + out: std.ArrayList(u8) = .empty, + nest: u32 = 0, + + 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 { + const e = expr orelse return dot; + var a: addr_lang.Addr = .{ .text = ex.text, .lim = null, .expr = e }; + const r = a.address(dot) orelse return fail(ex.why, "{s}", .{a.err}); + if (a.i < e.len) return fail(ex.why, "bad address syntax", .{}); + if (r.q0 > r.q1) return fail(ex.why, "addresses out of order", .{}); + return r; + } + + /// 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]; + 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 }); + } + + fn compile(ex: *Exec, pat: []const u8, c: u8) Failure!regexp.Regex { + return regexp.Regex.compile(pat) catch fail(ex.why, "bad regexp in {c} command", .{c}); + } + + fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match { + if (from > hi) return null; + return rx.find(ex.text, from, hi, hi) catch fail(ex.why, "{s}", .{addr_lang.e_slow}); + } + + /// 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); + switch (c.c) { + '\n' => return r, + 'a' => { + try ex.log(r.q1, r.q1, c.text); + return .{ .q0 = r.q1, .q1 = r.q1 }; + }, + 'i' => { + try ex.log(r.q0, r.q0, c.text); + return .{ .q0 = r.q0, .q1 = r.q0 }; + }, + 'c' => { + try ex.log(r.q0, r.q1, c.text); + return .{ .q0 = r.q0, .q1 = r.q0 }; + }, + 'd' => { + try ex.log(r.q0, r.q1, ""); + return .{ .q0 = r.q0, .q1 = r.q0 }; + }, + 'p' => { + try ex.out.appendSlice(ex.arena, ex.text[r.q0..r.q1]); + return r; + }, + '=' => { + // ecmd.c:665, printposn + if (c.text.len > 0) { + try ex.out.print(ex.arena, "{s}:#{d}", .{ ex.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 }); + 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), + 'x', 'y' => { + const ranges = if (c.re) |pat| try ex.looper(pat, r, c.c == 'x') else try ex.lines(r); + ex.nest += 1; + defer ex.nest -= 1; + var d = r; + for (ranges) |each| d = try ex.exec(c.sub.?, 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); + return r; + }, + 'm', 't' => { + const to = try ex.address(c.to, dot); + const bytes = ex.text[r.q0..r.q1]; + if (c.c == 't') { + try ex.log(to.q1, to.q1, bytes); + } 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); + } else if (r.q0 >= to.q1) { + try ex.log(to.q1, to.q1, bytes); + try ex.log(r.q0, r.q1, ""); + } else if (!(r.q0 == to.q0 and r.q1 == to.q1)) { + return fail(ex.why, "move overlaps itself", .{}); + } + return r; + }, + '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); + return r; + }, + else => return fail(ex.why, "unknown command {c}", .{c.c}), + } + } + + /// ecmd.c:443, s_cmd. + fn substitute(ex: *Exec, c: *Cmd, r: Range) Failure!Range { + 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; + if (m.start == m.end) { + if (op != null and m.start == op.?) { + p1 += 1; + continue; + } + p1 = m.end + 1; + } else p1 = m.end; + op = m.end; + n -= 1; + if (n > 0) continue; + var buf: std.ArrayList(u8) = .empty; + var i: usize = 0; + while (i < c.text.len) : (i += 1) { + const ch = c.text[i]; + if (ch == '\\' and i + 1 < c.text.len) { + i += 1; + const e = c.text[i]; + 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]); + } else try buf.append(ex.arena, ch); + } + try ex.log(clip(m.start), clip(m.end), buf.items); + did = true; + if (!c.global) break; + } + if (!did and ex.nest == 0) return fail(ex.why, "no substitution", .{}); + return r; + } + + /// 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 { + 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; + var p: usize = r.q0; + while (p <= r.q1) { + var tr: Range = undefined; + var sel_end: usize = undefined; + if (try ex.find(&rx, p, r.q1)) |m| { + if (m.start == m.end) { + if (op != null and m.start == op.?) { + p += 1; + continue; + } + p = m.end + 1; + } 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; + } else { + // no match, but y still takes what is left + if (xy or op.? > r.q1) break; + tr = .{ .q0 = clip(op.?), .q1 = r.q1 }; + p = @as(usize, r.q1) + 1; + sel_end = r.q1; + } + op = sel_end; + try out.append(ex.arena, tr); + } + return out.items; + } + + /// 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 { + 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); + if (e <= p) break; + try out.append(ex.arena, .{ .q0 = clip(p), .q1 = clip(e) }); + p = e; + } + return out.items; + } +}; + +/// The text after `ops`, which are in order and do not overlap. +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; + const out = try gpa.alloc(u8, n); + var at: usize = 0; + var from: usize = 0; + for (ops) |o| { + const keep = text[from..o.q0]; + @memcpy(out[at..][0..keep.len], keep); + at += keep.len; + @memcpy(out[at..][0..o.text.len], o.text); + at += o.text.len; + from = o.q1; + } + @memcpy(out[at..], text[from..]); + 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). +pub fn moveDot(dot: Range, ops: []const Op) Range { + var shift: i64 = 0; + for (ops) |o| { + if (dot.q0 == dot.q1 and o.q0 == dot.q0 and (o.text.len > 0)) { + const q: u32 = @intCast(@as(i64, dot.q0) + shift); + return .{ .q0 = q, .q1 = q + @as(u32, @intCast(o.text.len)) }; + } + if (o.q1 > dot.q0) break; + shift += @as(i64, @intCast(o.text.len)) - @as(i64, o.q1 - o.q0); + } + const q0: u32 = @intCast(@as(i64, dot.q0) + shift); + var shift1: i64 = 0; + for (ops) |o| { + if (o.q1 > dot.q1 or (o.q0 == o.q1 and o.q0 == dot.q1)) break; + shift1 += @as(i64, @intCast(o.text.len)) - @as(i64, o.q1 - o.q0); + } + const q1: u32 = @intCast(@max(@as(i64, q0), @as(i64, dot.q1) + shift1)); + return .{ .q0 = q0, .q1 = q1 }; +} + +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| { + if (err == error.Edit) std.debug.print("Edit: {s}\n", .{why.text()}); + return err; + }; + return apply(std.testing.allocator, text, res.ops); +} + +fn expectEdit(text: []const u8, command: []const u8, want: []const u8) !void { + const got = try edited(text, command); + defer std.testing.allocator.free(got); + try std.testing.expectEqualStrings(want, got); +} + +test "sam's classic commands" { + try expectEdit("foo x foo y foo\n", ",x/foo/c/foobar/", "foobar x foobar y foobar\n"); + try expectEdit("a b\nc d\n", ",x/ /c/_/", "a_b\nc_d\n"); + // As in acme (ecmd.c:810), y also takes the empty stretch after the last match. + try expectEdit("one\ntwo\nthree\n", ",y/\\n/c/X/", "X\nX\nX\nX"); + try expectEdit("aaa\n", ",s/a/b/g", "bbb\n"); + try expectEdit("aaa\n", ",s/a/b/", "baa\n"); + try expectEdit("aaa\n", ",s2/a/b/", "aba\n"); + try expectEdit("cat dog\n", ",s/[a-z]+/<&>/g", "<cat> <dog>\n"); + // x within x, g within x. + try expectEdit("int a;\nfloat b;\nint c;\n", ",x/.*\\n/g/^int/x/[a-z];/c/X;/", "int X;\nfloat b;\nint X;\n"); + try expectEdit("keep 1\ndrop 2\nkeep 3\n", ",x g/drop/d", "keep 1\nkeep 3\n"); + try expectEdit("keep 1\ndrop 2\nkeep 3\n", ",x v/keep/d", "keep 1\nkeep 3\n"); + // Lines, a/i, grouping, m and t. + try expectEdit("a\nb\n", ",x a/;/", "a\n;b\n;"); + try expectEdit("a\nb\n", "1 i/>/", ">a\nb\n"); + try expectEdit("abc\n", "/b/{\ni/[/\na/]/\n}", "a[b]c\n"); + try expectEdit("one\ntwo\n", "1 m $", "two\none\n"); + try expectEdit("one\ntwo\n", "1 t $", "one\ntwo\none\n"); + try expectEdit("one\ntwo\nthree\n", "2 d", "one\nthree\n"); + // pardes's line:col. + try expectEdit("one\ntwo\n", "2:2 i/-/", "one\nt-wo\n"); +} + +test "an Edit that fails halfway changes nothing, and says why in acme's words" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + for ([_][2][]const u8{ + .{ ",x/foo/c/bar/\n/nomatch/d", "no match for regexp" }, + .{ ",s/zzz/y/", "no substitution" }, + .{ "k", "unknown command k" }, + .{ ",x/a/{\na/1/\ni/2/\n}", "changes out of sequence" }, + .{ "}", "right brace with no left brace" }, + .{ "w /tmp/x", "w is not supported in pardes" }, + .{ ",s/(a)/\\1/", "no \\1: mvzr keeps no submatches" }, + .{ "1 m 1,2", "move overlaps itself" }, + }) |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.expectEqualStrings(c[1], why.text()); + } +} + +test "the dot an Edit leaves selects what a change put where it stood" { + const ops = [_]Op{.{ .q0 = 2, .q1 = 4, .text = "XYZ" }}; + try std.testing.expectEqual(Range{ .q0 = 2, .q1 = 5 }, moveDot(.{ .q0 = 2, .q1 = 2 }, &ops)); + try std.testing.expectEqual(Range{ .q0 = 6, .q1 = 7 }, moveDot(.{ .q0 = 5, .q1 = 6 }, &ops)); +} diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index c9bbd2a1..8bf70f36 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -29,6 +29,7 @@ wheel up 110 50 wheel up 110 50 wheel up 110 50 wheel up 110 50 +wheel up 110 50 wait 10000 Look enter, right-click wait 10000 Exec tab, middle-click wait 10000 Filter |
