diff options
| author | Gabriel Schneider <[email protected]> | 2026-10-01 20:50:48 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 22:07:48 -0300 |
| commit | 406f483288c7689bdd2e1f3f68b276c61ca9412a (patch) | |
| tree | b6398fb9974b91441563fd655d18ddaf182a2a5a /src/sam_edit.zig | |
| parent | b55cb01d2777760ed79b545dfca7115837591577 (diff) | |
| download | pardes-406f483288c7689bdd2e1f3f68b276c61ca9412a.tar.gz pardes-406f483288c7689bdd2e1f3f68b276c61ca9412a.zip | |
An Edit's held write: < | > commands no longer block their 9P connection (answered like a held read, so a status read or a filter reading the session's own mount runs beside it), a Tflush or hang-up of the write kills the commands' process groups and changes nothing; e loads by Get's way (asked once, clean after, undo puts the name back); ~ in e r w f B; B checks every name first; an Edit that runs commands is a write of its own, refused up front with other lines; Edit's +Errors output keeps the keyboard where it was; X goes in pane order; a refused write open says its errno; the reference's Coming from acme rows say what Get file's undo, failure and directory refusal, Putall's answer, Zerox across Dump and Incl's directories now do
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/sam_edit.zig')
| -rw-r--r-- | src/sam_edit.zig | 138 |
1 files changed, 112 insertions, 26 deletions
diff --git a/src/sam_edit.zig b/src/sam_edit.zig index 3f23aeff..d0c51bdb 100644 --- a/src/sam_edit.zig +++ b/src/sam_edit.zig @@ -12,8 +12,8 @@ //! 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. +//! write; `D`, `e`'s load and the name `f` sets, as marks on the file; `B` +//! and the reads of `e` and `r` through `World`, at once, as acme does them. //! //! Left out: the `'` address, `B <cmd` and `D <cmd` (a file list from a //! command), and `\1`..`\9` in `s` and sam's longest of alternatives (mvzr @@ -22,8 +22,9 @@ //! sequence (elog.c:130) and applies them anyway; here the Edit is refused. //! A command that fails (`|`, `<`, `>`) refuses the whole Edit, where sam //! warns and puts in what it wrote; `D` of a file the Edit changed is -//! refused rather than drop the changes; `e` of an edited file is refused -//! every time, not once. +//! refused rather than drop the changes; `e` loads its file once the Edit +//! is done, by Get's way (ninep/ctl.zig getFile), so the file takes no +//! other command in the same Edit. const std = @import("std"); const regexp = @import("regexp.zig"); const addr_lang = @import("ninep/addr.zig"); @@ -58,8 +59,10 @@ pub const File = struct { 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, + /// `e` loads this file into it once the Edit is done (the editor's Get + /// path, which renames the pane and leaves it clean); the file takes no + /// more commands in the Edit. + get: ?[]const u8 = null, /// A command left a dot in it. moved: bool = false, }; @@ -75,11 +78,17 @@ pub const Write = struct { file: u32, path: []const u8, bytes: []const u8, whole /// What the editor does for `B`, `e` and `r` while an Edit runs. pub const World = struct { ctx: *anyopaque, + /// Whether `B` could open the absolute `path`: false, and `why` set, + /// when it could not, before any is opened. + check: *const fn (ctx: *anyopaque, path: []const u8, why: *Why) bool, /// 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, + /// `e` over unsaved edits in the pane `id`: asked once, as Get asks; + /// true, and `why` set, when it is refused this time. + refuseGet: *const fn (ctx: *anyopaque, id: usize, why: *Why) bool, }; /// What an Edit came to: every file (those `B` opened after the ones it @@ -221,6 +230,28 @@ pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool { } } +/// Whether `command` holds a `<`, `|` or `>`: its write is answered only +/// when they have run (ninep/ctl.zig refuses other lines beside it). +pub fn runsCommands(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 }; + const Walk = struct { + fn any(c: ?*Cmd) bool { + var it = c; + while (it) |each| : (it = each.next) { + if (std.mem.indexOfScalar(u8, "<|>", each.c) != null) return true; + if (any(each.sub)) return true; + } + return false; + } + }; + while (true) { + const c = (ps.parse(0) catch return false) orelse return false; + if (Walk.any(c)) return true; + } +} + 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; @@ -642,7 +673,10 @@ const Exec = struct { /// `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 { + fn pathIn(ex: *Exec, fi: usize, name_in: []const u8) Failure![]const u8 { + // `~` and `~user` as Get, Incl and Save read them. + var home: [4096]u8 = undefined; + const name = try ex.arena.dupe(u8, @import("fs.zig").expandHome(name_in, &home)); 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 }); @@ -683,6 +717,7 @@ const Exec = struct { var fi = fi_in; var dot = dot_in; if (ex.files.items[fi].close) return fail(ex.why, "no current window", .{}); + if (ex.files.items[fi].get) |path| return fail(ex.why, "{s} is read in by e at the Edit's end: no more commands in it", .{path}); // A `"re"` address: the command runs in that file, from its dot. if (c.file_re) |pat| { fi = try ex.matchFile(pat); @@ -797,6 +832,9 @@ const Exec = struct { if (list.len > 0 and list[0] == '<') return fail(ex.why, "B <command is not supported: give the names", .{}); if (list.len == 0) return fail(ex.why, e_noname, .{}); const w = ex.world orelse return fail(ex.why, "B: no panes to open here", .{}); + // Every name first: one that cannot open opens none. + var checks = std.mem.tokenizeAny(u8, list, " \t"); + while (checks.next()) |name| if (!w.check(w.ctx, try ex.pathIn(fi, name), ex.why)) return error.Edit; var names = std.mem.tokenizeAny(u8, list, " \t"); while (names.next()) |name| { const path = try ex.pathIn(fi, name); @@ -822,17 +860,28 @@ const Exec = struct { } return r; }, - 'e', 'r' => { - // ecmd.c e_cmd: e reads a file over all of this one, r over - // dot; e names this file after it. + 'r' => { + // ecmd.c e_cmd: r reads a file over dot. + const path = (try ex.cmdName(fi, c.text, false)) orelse return fail(ex.why, e_noname, .{}); + try ex.log(fi, r.q0, r.q1, try ex.readFile(path), null); + return r; + }, + 'e' => { + // ecmd.c e_cmd: a file read over all of this one, which takes + // its name: Get's way (ctl.getFile), once the Edit is done, + // asking first, once, over unsaved edits. 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; + if (f.ops.items.len > 0) return fail(ex.why, "e: {s} has changes pending in this Edit", .{f.name}); + const given = std.mem.trim(u8, c.text, " \t"); + const path = if (given.len == 0) + (if (f.scratch) return fail(ex.why, e_noname, .{}) else f.name) + else + try ex.pathIn(fi, given); + const w = ex.world orelse return fail(ex.why, "can't open {s}: no files here", .{path}); + if (w.refuseGet(w.ctx, f.id, ex.why)) return error.Edit; + _ = try ex.readFile(path); + f.get = path; + f.name = path; return r; }, 'w' => { @@ -893,7 +942,7 @@ const Exec = struct { /// 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}); + if (f.ops.items.len > 0 or f.get != null) return fail(ex.why, "D: {s} has changes pending in this Edit", .{f.name}); f.close = true; } @@ -1166,7 +1215,23 @@ const Fixture = struct { .{ .name = "/src/+Errors", .text = "foo\n", .id = 3, .scratch = true }, }; + var opened: usize = 0; + + fn check(_: *anyopaque, path: []const u8, why: *Why) bool { + if (std.mem.eql(u8, path, "/src/new.c")) return true; + why.len = if (std.fmt.bufPrint(&why.buf, "B: cannot open {s}", .{path})) |said| said.len else |_| 0; + return false; + } + + /// The edited file, b.h, is asked about: refused. + fn refuseGet(_: *anyopaque, id: usize, why: *Why) bool { + if (id != 1) return false; + why.len = (std.fmt.bufPrint(&why.buf, "/src/b.h: Modified (e again to discard)", .{}) catch unreachable).len; + return true; + } + fn open(_: *anyopaque, path: []const u8, why: *Why) ?File { + opened += 1; if (std.mem.eql(u8, path, "/src/new.c")) return .{ .name = "/src/new.c", .text = "new foo\n", .id = 9 }; why.len = if (std.fmt.bufPrint(&why.buf, "B: cannot open {s}", .{path})) |said| said.len else |_| 0; return null; @@ -1188,7 +1253,7 @@ const Fixture = struct { fn edit(fx: *Fixture, from: usize, command: []const u8) !Result { var ctx: u8 = 0; - const world: World = .{ .ctx = &ctx, .open = open, .read = read }; + const world: World = .{ .ctx = &ctx, .check = check, .open = open, .read = read, .refuseGet = refuseGet }; return run(fx.arena_state.allocator(), &files, from, command, world, &fx.why); } @@ -1247,17 +1312,23 @@ test "b moves the current file, \"re\" addresses another, and f names one" { test "e and r read a file into the text, w writes it, D and B close and open panes" { var fx: Fixture = .init(); defer fx.deinit(); - // r replaces dot; e the whole text, and names the file after it. + // r replaces dot; e loads a file over all of it at the end, by Get's + // way, which names the pane after it. const r = try fx.edit(0, "2 r data"); try std.testing.expectEqualStrings("int foo;\nDATA\n", try fx.after(r, 0)); try std.testing.expect(!r.files[0].renamed); - const e = try fx.edit(0, "e /src/data"); - try std.testing.expectEqualStrings("DATA\n", try fx.after(e, 0)); - try std.testing.expectEqualStrings("/src/data", e.files[0].name); - try std.testing.expect(!e.files[0].clean); - // e of an edited file is refused, as acme's winclean refuses it. + const e = try fx.edit(0, "e data"); + try std.testing.expectEqualStrings("/src/data", e.files[0].get.?); + try std.testing.expectEqual(@as(usize, 0), e.files[0].ops.items.len); + // ...and takes no more commands, nor follows another change. + try std.testing.expectError(error.Edit, fx.edit(0, "e data\n,d")); + try std.testing.expectError(error.Edit, fx.edit(0, "1d\ne data")); + // e of an edited file is asked about, as Get asks. try std.testing.expectError(error.Edit, fx.edit(1, "e")); - try std.testing.expectEqualStrings("/src/b.h modified", fx.why.text()); + try std.testing.expectEqualStrings("/src/b.h: Modified (e again to discard)", fx.why.text()); + // `~` is the home directory, as Get and Save read it. + const home = try fx.edit(0, "w ~/t.txt"); + try std.testing.expect(home.writes[0].path[0] == '/' and std.mem.indexOfScalar(u8, home.writes[0].path, '~') == null); try std.testing.expectError(error.Edit, fx.edit(0, "r nothing")); try std.testing.expectEqualStrings("can't open /src/nothing: file does not exist", fx.why.text()); // w: all of it by default, to its own name; an address and a name. @@ -1282,6 +1353,10 @@ test "e and r read a file into the text, w writes it, D and B close and open pan try std.testing.expectEqualStrings("old foo\n", try fx.after(b, 4)); try std.testing.expectError(error.Edit, fx.edit(0, "B gone.c")); try std.testing.expectEqualStrings("B: cannot open /src/gone.c", fx.why.text()); + // Every name is checked first: one that cannot open opens none. + Fixture.opened = 0; + try std.testing.expectError(error.Edit, fx.edit(0, "B new.c gone.c")); + try std.testing.expectEqual(@as(usize, 0), Fixture.opened); } test "<, | and > are jobs: a command per address, its output the change" { @@ -1309,3 +1384,14 @@ test "<, | and > are jobs: a command per address, its output the change" { try std.testing.expectEqual(@as(usize, 2), x.jobs.len); try std.testing.expectEqual(@as(u32, 2), x.jobs[1].file); } + +test "an Edit that runs commands is told from one that does not" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + try std.testing.expect(runsCommands(a, ", | sort")); + try std.testing.expect(runsCommands(a, ",x/a/{\n< date\n}")); + try std.testing.expect(runsCommands(a, "X/c/ , > wc")); + try std.testing.expect(!runsCommands(a, ",s/|/>/g")); + try std.testing.expect(!runsCommands(a, ",x/</d")); +} |
