summaryrefslogtreecommitdiff
path: root/src
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 /src
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]>
Diffstat (limited to 'src')
-rw-r--r--src/ninep/ctl.zig69
-rw-r--r--src/sam_edit.zig36
2 files changed, 93 insertions, 12 deletions
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));