From c4ab29bce30f427decf3e199eebecfd415714a96 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 18:39:01 -0300 Subject: Edit runs sam's command language on a pane's body, as acme's Edit does, in one undo step Over 9P a rename everywhere was a loop of addr and data writes. Edit, from a tag, a pane ctl or exec, parses sam's commands as acme's edit.c does and runs them as ecmd.c does against the text as it was, collecting the changes in order as elog.c logs them and applying them at once: addresses (the addr file's, line:col included), x y g v c a i d s p = m t, u alone, and { }. One undo step; any error, in acme's words, changes nothing, fails a ctl write and is logged. p and = print to the directory's +Errors. Left out, and said: the file commands, the pipes, \1-\9 (mvzr keeps no submatches), and changes out of sequence, which acme applies with a warning. Co-Authored-By: Claude Opus 5.5 --- src/builtins.zig | 31 +++ src/config.zig | 1 + src/fs-help.txt | 2 +- src/ninep/ctl.zig | 17 ++ src/pardes.zig | 1 + src/sam_edit.zig | 667 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 src/sam_edit.zig (limited to 'src') 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/; 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", " \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)); +} -- cgit v1.3