//! acme's Edit (editors/acme/edit.c, ecmd.c, elog.c): sam's command //! 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. //! //! What touches more than the text is the caller's (src/EditCmd.zig): `<`, //! `|` and `>` are collected as jobs, a command to run per address, whose //! output fills the change they hold the place of; `w` as the bytes to //! write; `D`, and the names `e` and `f` set, as marks on the file; `B` and //! the reads of `e` and `r` through `World`, at once, as acme does them. //! //! Left out: the `'` address, `B `) refuses the whole Edit, where sam //! warns and puts in what it wrote; `D` of a file the Edit changed is //! refused rather than drop the changes; `e` of an edited file is refused //! every time, not once. const std = @import("std"); const regexp = @import("regexp.zig"); const addr_lang = @import("ninep/addr.zig"); const modal = @import("modal.zig"); /// The next place after `p` a search may start: a rune on, and past the /// text's end once there, which ends a loop over `p <= end`. fn stepRune(text: []const u8, p: usize) usize { return if (p >= text.len) p + 1 else modal.nextRune(text, p); } pub const Range = addr_lang.Range; /// 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 }; /// `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 { 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, }; 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]; } }; /// 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, next: ?*Cmd = null, num: i32 = 0, global: bool = false, to: ?[]const u8 = null, }; /// 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, 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'; 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; } // An a, c or i text that never met its `.` line is refused, as an // unclosed `{` is: sam takes the end of input for one, but a block that // 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", .{}); 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 .{ .files = ex.files.items, .cur = cur, .undo = cmds.num }; } // editthread: each command from the current file, which `b` changes. var it: ?*Cmd = cmds; 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, }; } /// 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 }; /// As `needsMore`, saying which: while a text block is open only a `.` /// line can end it, so a caller need not parse the block again for every /// line of text in it (ninep/ctl.zig, editEnd), which was quadratic. pub fn waitsFor(arena: std.mem.Allocator, command: []const u8) Wait { var why: Why = .{}; const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return .none; defer arena.free(src); var ps: Parser = .{ .arena = arena, .src = src, .why = &why }; while (true) { const c = ps.parse(0) catch return if (ps.open_text) .text else if (std.mem.eql(u8, why.text(), "unmatched `{'") or groupOpen(command)) .group else .none; if (c == null) return if (ps.open_text) .text else .none; } } /// A `{` block not closed yet by its `}` line: a command in it that fails /// to parse still leaves the block to end at its `}`, so the error is that /// command's, not the `}`'s taken as a line of its own. fn groupOpen(command: []const u8) bool { var depth: usize = 0; var lines = std.mem.splitScalar(u8, command, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (std.mem.eql(u8, line, "}")) { depth -|= 1; } else if (std.mem.endsWith(u8, line, "{")) depth += 1; } 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; var ps: Parser = .{ .arena = arena, .src = src, .why = &why }; while (true) { const c = ps.parse(0) catch return std.mem.eql(u8, why.text(), "unmatched `{'") or ps.open_text; if (c == null) return ps.open_text; } } 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 = "", /// The last `parse` that answered null met a `}`, not the end. 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 { 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. 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; switch (c) { '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') { 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 '", .{}), '"' => { // 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, } } 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'); } ps.open_text = true; 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: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 { 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, .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(), '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; } else if (ps.nextc()) |d| if (d >= '1' and d <= '9') { // `s/a/b/2`, the count after, as some write it for // sam's `s2/a/b/`. cmd.num = ps.getnum(1); }; } try ps.atnl(); }, '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' 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", .{}); 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 = 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(null)) orelse return fail(ps.why, "bad address", .{}); try ps.atnl(); }, 'u' => { 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) { if (ps.skipbl() == '\n') ps.i += 1; ps.braced = false; const next = (try ps.parse(nest + 1)) orelse { // sam reads on for the `}`; an Edit has no more to read. if (!ps.braced) return fail(ps.why, "unmatched `{{'", .{}); break; }; if (last) |l| l.next = next else cmd.sub = next; last = next; } }, '}' => { ps.braced = true; try ps.atnl(); if (nest == 0) return fail(ps.why, "right brace with no left brace", .{}); return null; }, else => return fail(ps.why, "unknown command {c}", .{c}), } return cmd; } }; // ---- running: ecmd.c, logging: elog.c ---- const e_noname = "no file name given"; const Exec = struct { arena: std.mem.Allocator, why: *Why, 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); } 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(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}) else 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, 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 ops.append(ex.arena, .{ .q0 = q0, .q1 = q1, .text = bytes, .job = job }); } fn compile(ex: *Exec, pat: []const u8, c: u8) Failure!regexp.Regex { return regexp.Regex.compile(pat) catch |err| switch (err) { error.Anchor => fail(ex.why, "{s}", .{regexp.Regex.e_anchor}), error.TooLong => fail(ex.why, "{s}", .{regexp.Regex.e_long}), 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 => 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}), }; } /// A match covers the runes it touches, as an address's does /// (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, 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(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; } /// 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(fi, r.q1, r.q1, c.text, null); return .{ .q0 = r.q1, .q1 = r.q1 }; }, 'i' => { try ex.log(fi, r.q0, r.q0, c.text, null); return .{ .q0 = r.q0, .q1 = r.q0 }; }, 'c' => { try ex.log(fi, r.q0, r.q1, c.text, null); return .{ .q0 = r.q0, .q1 = r.q0 }; }, 'd' => { try ex.log(fi, r.q0, r.q1, "", null); return .{ .q0 = r.q0, .q1 = r.q0 }; }, 'p' => { 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}", .{ 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, 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, fi, r), 'x', 'y' => { 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.?, fi, each); return d; }, 'g', 'v' => { var rx = try ex.compile(c.re.?, c.c); 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(fi, c.to, r); const bytes = txt[r.q0..r.q1]; if (c.c == 't') { try ex.log(fi, to.q1, to.q1, bytes, null); } else if (r.q1 <= to.q0) { // ecmd.c:408, move 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(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", .{}); } 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, fi, r); return r; }, 'b' => { // ecmd.c b_cmd, tofile: the file named so, exactly or in // the current file's directory. const name = std.mem.trim(u8, c.text, " \t"); const at = ex.lookFile(name) orelse ex.lookFile(try ex.pathIn(fi, name)); const to = if (at) |i| (if (ex.files.items[i].scratch) null else i) else null; const target = to orelse return fail(ex.why, "no such file \"{s}\"", .{name}); // The `.` goes with it in the line printed. ex.cur = target; if (ex.nest == 0) try ex.out.appendSlice(ex.arena, try ex.fileLine(target)); return r; }, 'B' => { const list = std.mem.trim(u8, c.text, " \t"); if (list.len > 0 and list[0] == '<') return fail(ex.why, "B { const list = std.mem.trim(u8, c.text, " \t"); if (list.len > 0 and list[0] == '<') return fail(ex.why, "D { // ecmd.c e_cmd: e reads a file over all of this one, r over // dot; e names this file after it. const f = &ex.files.items[fi]; if (c.c == 'e' and f.dirty and !f.scratch) return fail(ex.why, "{s} modified", .{f.name}); const own = f.name; const path = (try ex.cmdName(fi, c.text, c.c == 'e')) orelse return fail(ex.why, e_noname, .{}); const bytes = try ex.readFile(path); const q: Range = if (c.c == 'e') .{ .q0 = 0, .q1 = clip(txt.len) } else r; try ex.log(fi, q.q0, q.q1, bytes, null); if (c.c == 'e' and std.mem.eql(u8, path, own)) ex.files.items[fi].clean = true; return r; }, 'w' => { if (ex.files.items[fi].ops.items.len > 0) return fail(ex.why, "can't write file with pending modifications", .{}); const path = (try ex.cmdName(fi, c.text, false)) orelse return fail(ex.why, "no name specified for 'w' command", .{}); try ex.writes.append(ex.arena, .{ .file = @intCast(fi), .path = path, .bytes = try ex.arena.dupe(u8, txt[r.q0..r.q1]), .whole = r.q0 == 0 and r.q1 == txt.len, }); return r; }, 'f' => { _ = try ex.cmdName(fi, c.text, true); try ex.out.appendSlice(ex.arena, try ex.fileLine(fi)); return r; }, 'X', 'Y' => { // ecmd.c filelooper: the files whose line matches (X) or // does not (Y), each from its own dot; X alone is every // file with a name. if (ex.looping) return fail(ex.why, "can't nest {c} command", .{c.c}); ex.looping = true; defer ex.looping = false; ex.nest += 1; defer ex.nest -= 1; var chosen: std.ArrayList(usize) = .empty; for (ex.files.items, 0..) |f, i| { if (f.close) continue; if (c.re == null and f.scratch) continue; if (c.re == null or (try ex.fileMatch(i, c.re.?)) == (c.c == 'X')) try chosen.append(ex.arena, i); } for (chosen.items) |i| { const d = try ex.exec(c.sub.?, i, ex.files.items[i].dot); ex.files.items[i].dot = d; ex.files.items[i].moved = true; } return r; }, '<', '|', '>' => { // ecmd.c runpipe: the command's output replaces dot (`<`, // `|`), dot is its input (`|`, `>`), and `>`'s output is // printed. const command = std.mem.trim(u8, c.text, " \t"); if (command.len == 0) return fail(ex.why, "no command specified for {c}", .{c.c}); if (ex.jobs.items.len >= max_jobs) return fail(ex.why, "too many commands: more than {d} for one Edit", .{max_jobs}); const job: u32 = @intCast(ex.jobs.items.len); try ex.jobs.append(ex.arena, .{ .c = c.c, .command = command, .file = @intCast(fi), .q0 = r.q0, .q1 = r.q1, .at = ex.out.items.len }); if (c.c != '>') try ex.log(fi, r.q0, r.q1, "", job); return r; }, else => return fail(ex.why, "unknown command {c}", .{c.c}), } } /// ecmd.c D1: closed once the Edit is done. One with changes pending /// in this Edit is refused rather than have them dropped. fn closeFile(ex: *Exec, fi: usize) Failure!void { const f = &ex.files.items[fi]; if (f.ops.items.len > 0) return fail(ex.why, "D: {s} has changes pending in this Edit", .{f.name}); f.close = true; } /// ecmd.c:443, s_cmd. fn substitute(ex: *Exec, c: *Cmd, 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, txt, p1, r.q1)) orelse break; if (m.start == m.end) { if (op != null and m.start == op.?) { p1 = stepRune(txt, p1); continue; } p1 = stepRune(txt, m.end); } 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, txt[m.start..m.end]); } else try buf.append(ex.arena, ch); } try ex.log(fi, clip(m.start), clip(m.end), buf.items, null); did = true; if (!c.global) break; } if (!did and ex.nest == 0) return if (r.q0 == r.q1) fail(ex.why, "no substitution: dot is empty", .{}) else 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, 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; var p: usize = r.q0; while (p <= r.q1) { var tr: Range = undefined; var sel_end: usize = undefined; if (try ex.find(&rx, txt, p, r.q1)) |m| { if (m.start == m.end) { if (op != null and m.start == op.?) { p = stepRune(txt, p); continue; } 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; } 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, 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, 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; } return out.items; } }; /// 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; 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` (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| { 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(), &.{.{ .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.files[0].ops.items); } 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 "an x over 100k lines is 100k searches, each with its own step budget" { const th = @import("ninep/testing.zig"); const line = "x" ** 80 ++ "\n"; const text = try std.testing.allocator.alloc(u8, line.len * 100_000); defer std.testing.allocator.free(text); for (0..100_000) |k| @memcpy(text[line.len * k ..][0..line.len], line); const p = try th.withFile(std.testing.allocator, text); defer p.deinit(); const tree = @import("ninep/tree.zig"); const r = th.wr(p, tree.Node.of(th.serialOf(p), .ctl), "Edit ,x/x+/c/z/\n"); try std.testing.expectEqual(tree.Status.ok, r.reply.status); try std.testing.expect(std.mem.startsWith(u8, p.panes[0].?.file.?.content, "z\nz\n")); } 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("aaa\n", ",s/a/b/2", "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"); // ^ at every line start in a pattern that spans lines (dogfood round 5). try expectEdit("x = 1\ndef a\ndef b\n", ",x/^def .*\\n/a/X\\n/", "x = 1\ndef a\nX\ndef b\nX\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 /nomatch/" }, .{ ",s/zzz/y/", "no substitution" }, .{ "s/foo/y/", "no substitution: dot is empty" }, .{ "k", "unknown command k" }, .{ ",x/a/{\na/1/\ni/2/\n}", "changes out of sequence" }, .{ "}", "right brace with no left brace" }, .{ ",x/foo/{", "unmatched `{'" }, .{ ",x/foo/{\nd", "unmatched `{'" }, .{ "B are jobs: a command per address, its output the change" { var fx: Fixture = .init(); defer fx.deinit(); const res = try fx.edit(0, ",x/foo|bar/ | tr a-z A-Z\n$ > wc -c\n$ < date"); try std.testing.expectEqual(@as(usize, 4), res.jobs.len); try std.testing.expectEqual(@as(u8, '|'), res.jobs[0].c); try std.testing.expectEqualStrings("tr a-z A-Z", res.jobs[0].command); try std.testing.expectEqualStrings("foo", res.files[0].text[res.jobs[0].q0..res.jobs[0].q1]); try std.testing.expectEqualStrings("bar", res.files[0].text[res.jobs[1].q0..res.jobs[1].q1]); try std.testing.expectEqual(@as(u8, '>'), res.jobs[2].c); try std.testing.expectEqual(@as(u8, '<'), res.jobs[3].c); // `>` changes nothing; `|` and `<` hold their output's place. const ops = res.files[0].ops.items; try std.testing.expectEqual(@as(usize, 3), ops.len); try std.testing.expectEqual(@as(?u32, 0), ops[0].job); try std.testing.expectEqual(@as(?u32, 1), ops[1].job); try std.testing.expectEqual(@as(?u32, 3), ops[2].job); // ...out of order with the x before it: `1 <` reaches back. try std.testing.expectError(error.Edit, fx.edit(0, ",x/bar/ | cat\n1 < date")); try std.testing.expectEqualStrings("changes out of sequence", fx.why.text()); // Across files with X: each job is its file's. const x = try fx.edit(0, "X/\\.c$/ , | sort"); try std.testing.expectEqual(@as(usize, 2), x.jobs.len); try std.testing.expectEqual(@as(u32, 2), x.jobs[1].file); }