summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 19:03:48 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit4cea4751960b359561997cb040f05179923cb80e (patch)
tree19834dde787a2fc4315211f55c20421f183b79d7
parent8ce3e3c62a22d0e70df4f241ff28de6f6cae78d3 (diff)
downloadpardes-4cea4751960b359561997cb040f05179923cb80e.tar.gz
pardes-4cea4751960b359561997cb040f05179923cb80e.zip
A write of an Edit block reaches Edit whole, on a pane's ctl, the root's and exec
Every write was split into lines, so a { group or an a/c/i text block written to ctl or exec became lines of their own: i/.../ and } ran as shell command panes that exited 127. An Edit line now takes the lines after it while its group is open or its text block waits for its . line (sam_edit.needsMore, asking sam's parser), on all three; the root ctl takes Edit at the active pane. s/re/text/N counts as sN/re/text/ does. The docs and the skill show a block written by heredoc or env printf, since bash's printf writes line by line, and take round 5's doc list: get refusing once, the (xN) on a repeated err, limit bounding only a forward search's end, line:col's clamping, and $NINE_MOUNT under 9ns --unix. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--.agents/skills/pardes-9p/SKILL.md30
-rw-r--r--docs/fs.md13
-rw-r--r--src/ninep/ctl.zig69
-rw-r--r--src/sam_edit.zig36
4 files changed, 127 insertions, 21 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 57ebd6cf..d34fa8b3 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -40,8 +40,9 @@ the `pid`, `version` and `panes` of a session new enough to serve it.
machine. It is not the per-pane kernel mount the `Tty9p` builtin makes, which
gives one pane's shell `$PARDES_MOUNT`; see [docs/v9fs.md](../../../docs/v9fs.md)
for that. Either mountpoint serves the same tree. A `9ns --unix SOCKET -- cmd`
-mount exists only inside `cmd`'s private namespace; to share one, use the
-registry (`9ns --mntgen`).
+mount exists only inside `cmd`'s private namespace, and there `$NINE_MOUNT` is
+the session's own root (`$NINE_MOUNT/index`), not a registry; to share one,
+use the registry (`9ns --mntgen`).
A running session serves whatever binary started it. If a listing does not
match this document, that session predates the change; restart it.
@@ -155,7 +156,21 @@ Rename everywhere, or any sam edit, is one write: `echo 'Edit ,x/foo/c/bar/'
v c a i d s p = m t u` and `{ }` (commands in braces one to a line, so from
exec or a tag, not a one-line ctl write). Its changes are one undo step, and
one that fails changes nothing and fails the write with acme's words (`Edit:
-no substitution`), logged as `err`. `p` and `=` print to the directory's
+no substitution`), logged as `err`. A block goes in one write to the pane's
+`ctl`, the root `ctl` (the active pane) or `exec`: an `Edit` line takes the
+lines after it until its `{` closes or its `a`/`c`/`i` text ends with `.`.
+bash's builtin `printf` writes line by line, so use a heredoc or `env printf`:
+
+```sh
+cat > $pane/ctl <<'END'
+Edit ,x/area_of/{
+i/[/
+a/]/
+}
+END
+env printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $pane/ctl
+```
+ `p` and `=` print to the directory's
`+Errors`. Not there: `b B D e r w f X Y`, `< | >`, and `\1`-`\9` in `s`.
| Operation | Shell | Python client |
@@ -172,7 +187,7 @@ inserts after the first: write `addr` again before each replacement.
Truncating `data` is pardes's own (acme ignores OTRUNC and always inserts).
| Read the selection | `cat $pane/dot` (offsets), `cat $pane/sel` (text) | the same two reads |
| Select the addressed range | `cp $pane/addr $pane/dot` | `client.write(pane + '/dot', client.read(pane + '/addr'))` |
-| Reload from disk | `echo get > $pane/ctl` | `client.write(pane + '/ctl', b'get\n')` |
+| Reload from disk | `echo get > $pane/ctl` (refused once while there are unsaved edits; again discards) | `client.write(pane + '/ctl', b'get\n')` |
| Close it | `rmdir $pane` | `client.remove(pane)` |
`addr`, `dot` and `limit` each read the pair of offsets they also accept, which
@@ -194,8 +209,11 @@ past a step budget (about 300 ms) fails with `regular expression search
gave up, ...`. A failed address says
why (`no match for regexp`, `address out of range`) and leaves no address:
`data` refuses until the next good one, so a missed target is never
-written at the old one. Moving `dot` scrolls the pane to it. `limit` bounds a
-search and reads empty until set; truncate it to lift it.
+written at the old one. Moving `dot` scrolls the pane to it. `limit` bounds
+only the end of a forward search, as in acme, and reads empty until set;
+truncate it to lift it. In `12:5` the column counts bytes from 1 and clamps
+past the end of the line; a line past the end is `address out of range`.
+A refused write repeated the same way is one `err` line counted, `(x4)`.
`dirty`, `mark` and `scroll` read `0` or `1` and take `0` or `1`: whether the
buffer differs from its file, whether a write pushes an undo point, and whether
diff --git a/docs/fs.md b/docs/fs.md
index d60b4e73..9a77beee 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -61,6 +61,8 @@ private namespace:
9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"'
```
+(Under `9ns --unix`, `$NINE_MOUNT` is that session's root itself.)
+
A `9ns --unix` mount lives in the private namespace of the command it runs,
and nothing outside that command sees it. The mount everyone on the machine
shares is the registry one, `9ns --mntgen` (default `/mnt/9p`): every running
@@ -360,14 +362,19 @@ they also accept, so copying one onto another is all that acme's `addr=dot`,
address expression (`#0,#5`, `/pattern/`, `2+1`, and pardes's own `12:5`,
below); `addr` selects what `data`
and `xdata` read or replace, `dot` is the editor's own selection and moving it
-scrolls the pane into view, and `limit` bounds a search and reads empty until
-it is set. Truncating `dot` empties it, truncating `limit` lifts it, and
+scrolls the pane into view, and `limit` bounds only the end of a forward
+search, as acme's does, and reads empty until it is set. Truncating `dot` empties it, truncating `limit` lifts it, and
truncating `addr` leaves it as it is (below).
A rename everywhere, or any other sam edit, is one write to the pane's
`ctl`: `Edit ,x/foo/c/bar/` runs acme's Edit (docs/tags.md) on the body as
one undo step; a failure fails the write with acme's words and changes
-nothing.
+nothing. A write is one message a line, except that an `Edit` line takes
+the lines after it while its `{` group is open or its `a`, `c` or `i` text
+block waits for its `.` line, on a pane's `ctl`, the root's (at the active
+pane) and `exec` alike; an unclosed group is refused (``unmatched `{'``).
+bash's builtin `printf` writes a line at a time, so write a block with a
+heredoc or `env printf`.
`line:col` is a pardes extension to sam's addresses, the spelling Look
takes in `file:12:5`: `12:5` is the point at line 12, column 5, and it
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 18068ab1..b11692e0 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -87,6 +87,37 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}
+/// A write's messages, a line each, except that an `Edit` line takes the
+/// lines after it while its `{` group or `a`/`c`/`i` text block is open
+/// (sam_edit.needsMore): a block written whole reaches Edit whole, never
+/// as lines of their own.
+const Messages = struct {
+ p: *Pardes,
+ data: []const u8,
+ at: usize = 0,
+
+ fn init(p: *Pardes, data: []const u8) Messages {
+ return .{ .p = p, .data = data };
+ }
+
+ fn next(m: *Messages) ?[]const u8 {
+ if (m.at >= m.data.len) return null;
+ const start = m.at;
+ var end = std.mem.indexOfScalarPos(u8, m.data, start, '\n') orelse m.data.len;
+ m.at = end + 1;
+ const first = std.mem.trim(u8, m.data[start..end], " \t\r");
+ const edit = "Edit";
+ if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t'))
+ return first;
+ const sam = @import("../sam_edit.zig");
+ while (m.at < m.data.len and sam.needsMore(m.p.scratch.allocator(), std.mem.trim(u8, m.data[start..end], " \t\r")[edit.len..])) {
+ end = std.mem.indexOfScalarPos(u8, m.data, m.at, '\n') orelse m.data.len;
+ m.at = end + 1;
+ }
+ return std.mem.trim(u8, m.data[start..end], " \t\r");
+ }
+};
+
// ---- look and exec ----
/// One command per line, checked whole before any line runs: a line is the
@@ -96,11 +127,11 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const text = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |text| {
if (text.len == 0) continue;
- for (text) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
+ // Only an Edit block holds newlines (Messages).
+ for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return Reply.fail(req.tag, E.INVAL);
if (!apply) continue;
const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
@@ -226,7 +257,8 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
const word = line[0 .. sp orelse line.len];
const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else "";
const b = std.meta.stringToEnum(Builtin, word) orelse return refuse(p, req, "unknown control message", line);
- if (builtins.registry.scope(b) != scope)
+ // Edit is the one pane word the root takes too: at the active pane.
+ if (builtins.registry.scope(b) != scope and b != .Edit)
return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) "/ctl" else "pane/<n>/ctl");
if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line);
if (arg.len == 0 and builtins.registry.requiresArg(b)) return refuse(p, req, "wrong #args in control message", line);
@@ -342,9 +374,8 @@ pub fn readRoot(p: *Pardes, req: Req) Reply {
pub fn writeRoot(p: *Pardes, req: Req) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const line = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |line| {
if (line.len == 0) continue;
if (!apply) {
if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
@@ -453,9 +484,8 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var held = !other and pane.fs.lock != null;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const line = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |line| {
if (line.len == 0) continue;
// A line before this one may have closed the pane (`Del`).
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
@@ -787,6 +817,23 @@ test "Edit through a pane's ctl renames every foo in one undo step, and a failed
try testing.expectEqualStrings("foo x foo y foo\n", rd(p, body, 0, 64).bytes);
}
+test "a write of an Edit block carries it whole, to a pane's ctl, the root's ctl and exec" {
+ const p = try withFile(testing.allocator, "foo x\nfoo y\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const body = Node.of(serial, .body);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\ni/[/\na/]/\n}\n").reply.status);
+ try testing.expectEqualStrings("[foo] x\n[foo] y\n", rd(p, body, 0, 64).bytes);
+ p.active = p.paneBySerial(serial).?;
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Edit ,x/\\[/{\nc/</\n}\n").reply.status);
+ try testing.expectEqualStrings("<foo] x\n<foo] y\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Edit $a\nend\n.\n").reply.status);
+ try testing.expectEqualStrings("<foo] x\n<foo] y\nend\n", rd(p, body, 0, 64).bytes);
+ // An unclosed group is refused, and nothing after it runs as a line.
+ try testing.expectEqual(E.IO, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\nd\n").errno());
+ try testing.expectEqualStrings("<foo] x\n<foo] y\nend\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/sam_edit.zig b/src/sam_edit.zig
index e8e90b67..679823da 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -82,6 +82,19 @@ pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const
return .{ .ops = ex.ops.items, .dot = d, .printed = ex.out.items };
}
+/// 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;
@@ -97,6 +110,8 @@ const Parser = struct {
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,
why: *Why,
fn getch(ps: *Parser) ?u8 {
@@ -250,6 +265,7 @@ const Parser = struct {
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 "";
@@ -292,7 +308,11 @@ const Parser = struct {
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();
},
@@ -636,6 +656,7 @@ test "sam's classic commands" {
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", "<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");
@@ -676,6 +697,19 @@ test "an Edit that fails halfway changes nothing, and says why in acme's words"
}
}
+test "an Edit wants the lines after it until its braces and text blocks close" {
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const a = arena_state.allocator();
+ try std.testing.expect(needsMore(a, ",x/foo/{"));
+ try std.testing.expect(needsMore(a, ",x/foo/{\ni/[/"));
+ try std.testing.expect(!needsMore(a, ",x/foo/{\ni/[/\n}"));
+ try std.testing.expect(needsMore(a, "$a\nline one"));
+ try std.testing.expect(!needsMore(a, "$a\nline one\n."));
+ try std.testing.expect(!needsMore(a, ",s/{/x/"));
+ try std.testing.expect(!needsMore(a, ",x/foo/c/bar/"));
+}
+
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));