diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 18:39:01 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | c4ab29bce30f427decf3e199eebecfd415714a96 (patch) | |
| tree | 69321fe5261c34dc27586e6d0a233d779368a49c /src/sam_edit.zig | |
| parent | 00298cc24e12432a4120f4efb68728c008d9620f (diff) | |
| download | pardes-c4ab29bce30f427decf3e199eebecfd415714a96.tar.gz pardes-c4ab29bce30f427decf3e199eebecfd415714a96.zip | |
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 <[email protected]>
Diffstat (limited to 'src/sam_edit.zig')
| -rw-r--r-- | src/sam_edit.zig | 667 |
1 files changed, 667 insertions, 0 deletions
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)); +} |
