summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md8
-rw-r--r--docs/fs.md34
-rw-r--r--src/Messages.zig6
-rw-r--r--src/builtins.zig31
-rw-r--r--src/fs.zig6
-rw-r--r--src/ninep/ctl.zig42
6 files changed, 100 insertions, 27 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index ec5f811f..a17a5dac 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -138,9 +138,11 @@ width, then `current` or `notcurrent` — and takes `get` (reload from disk),
`lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`,
`Collapse`). Session builtins and settings go to the root `ctl`, which reads
back every setting in the syntax it takes. A ctl write is checked whole
-first and refused as `unknown control message "X"` (EINVAL) and the like; a
-builtin that would open a prompt (`Save` on a scratch, bare `Find`) fails
-there instead of prompting. The lock binds only clients that take it, and is held by the
+first and refused as `unknown control message "X"` (EINVAL) and the like,
+a required argument missing included (`wrong #args ... "Mount"`); then a line
+whose builtin reports an error fails the write with that error and the line
+(EIO), after the lines before it took effect. A `Save` on a scratch fails
+rather than prompt. The lock binds only clients that take it, and is held by the
open that wrote it, so a shell holds an fd across the edit:
`exec 3>$pane/ctl; echo lock >&3; ...; exec 3>&-`.
diff --git a/docs/fs.md b/docs/fs.md
index b97f34fa..c08500f1 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -104,18 +104,28 @@ Config window's, not settings. A pane's `ctl` takes the builtins that act on
a pane (`Del`, `Save f`, `Collapse`, `Find pat`) beside acme's `get`, `lock`
and `unlock`. The words are case-sensitive and do not alias: acme's verbs
are lowercase and the builtins keep their tag spelling, so `Get` is no word
-and `del` none either. A write is checked whole before any line runs, and a
-line is refused in Plan 9's words for a ctl (kernel/misc/parse.c:82-97):
-`unknown control message "X"`, `wrong #args in control message "X"` for an
-argument to a builtin that takes none, `bad value in control message "X"`
-for a setting's value it does not take, and `not a session control message
-"X"` or `not a window control message "X"` for a word of the other ctl; 9ns
-maps them all to EINVAL. A builtin that would ask at a prompt for its
-argument (`Save` on a scratch, `Find` bare) fails the write with `control
-message needs its argument` rather than open one nobody is there to answer,
-though a click on the same word written to `exec` still opens it. Like any
-write, a ctl write answers once the editor has performed what it asked for
-(a save written, a shell started).
+and `del` none either.
+
+A write is checked whole before any line of it runs, and a line is refused
+in Plan 9's words for a ctl (kernel/misc/parse.c:82-97), quoting the line:
+`unknown control message "X"`; `wrong #args in control message "X"` for an
+argument to a builtin that takes none, or none to one that needs it (`Msg`,
+`Mount`, `Find`, a setting's value but a switch's, which flips bare);
+`bad value in control message "X"` for a setting's value it does not take;
+and `not a session control message "X"` or `not a window control message
+"X"` for a word of the other ctl. 9ns maps them all to EINVAL, and a write
+refused here has done nothing. A line that then fails as it runs fails the
+write with the error the editor reports for it and the line, e.g. `Mount:
+AlreadyMounted "Mount peer /tmp/s"` (EIO); so does `control message needs
+its argument "Save"`, for a builtin that would have asked at a prompt (a
+`Save` on a scratch) rather than open one nobody is there to answer. The
+lines before a failing one have taken effect and those after it never run,
+which is what acme's ctl loop does (editors/acme/xfid.c:600-790). An error
+that only happens as the editor performs what a line asked for -- a `Save`
+whose disk write fails -- is reported in the editor and /log, not in the
+write's answer. Like any write, a ctl write answers once the editor has
+performed what it asked for (a save written, a shell started). A click on
+the same word, or the word written to `exec`, still opens its prompt.
`/commands` lists every builtin the registry holds, in registry order, one
a line: its word, `arg` when it takes one, and `root` or `pane` for the ctl
diff --git a/src/Messages.zig b/src/Messages.zig
index c7ff09be..f4ea1c3e 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -388,6 +388,12 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation;
+ // A builtin a ctl write runs: its first error is also the write's.
+ if (p.fs.no_prompt and p.fs.failure_len == 0) {
+ const n = @min(text.len, p.fs.failure.len);
+ @memcpy(p.fs.failure[0..n], text[0..n]);
+ p.fs.failure_len = @intCast(n);
+ }
setMessage(p, id, text);
}
diff --git a/src/builtins.zig b/src/builtins.zig
index ed0a07dd..2dac8a59 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -145,6 +145,22 @@ pub const registry = struct {
return true;
}
+ /// Whether the word means nothing without its argument (`Mount`,
+ /// `Msg`, a setting's value), or would ask for it at a prompt (`Find`):
+ /// a ctl refuses it bare before any line of the write runs. A builtin
+ /// says `pub const requires_arg = true;` beside `takes_arg`.
+ pub fn requiresArg(b: Builtin()) bool {
+ inline for (manualBuiltinList(), 0..) |T, i|
+ if (@intFromEnum(b) == i) return @hasDecl(T, "requires_arg") and T.requires_arg;
+ inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
+ if (@intFromEnum(b) == i) return switch (setting.action) {
+ // a switch flips bare, and DumpDir bare is the default
+ .toggle, .transition, .scene, .dump_dir => false,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms => true,
+ };
+ unreachable;
+ }
+
pub fn scope(b: Builtin()) Scope {
inline for (manualBuiltinList(), 0..) |T, i|
if (@intFromEnum(b) == i) return if (@hasDecl(T, "scope")) T.scope else .pane;
@@ -237,6 +253,7 @@ comptime {
pub const Look = struct {
pub const takes_arg = true;
+ pub const requires_arg = true;
// a look's answer is the pane it opens or the place it jumps to; its own
// name on the message row would only be noise over that
pub const quiet = true;
@@ -247,6 +264,7 @@ pub const Look = struct {
pub const Exec = struct {
pub const takes_arg = true;
+ pub const requires_arg = true;
pub fn run(c: Ctx) void {
// the destination pane is Look's business (it focuses what answered);
// an execute deliberately leaves you where you were
@@ -311,6 +329,7 @@ pub const Detach = struct {
pub const Mount = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime !enabled) unreachable;
@@ -325,6 +344,7 @@ pub const Mount = struct {
pub const Unmount = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime !enabled) unreachable;
@@ -339,6 +359,7 @@ pub const Unmount = struct {
pub const Msg = struct {
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const quiet = true; // it IS the message row
pub fn run(c: Ctx) void {
if (c.arg) |text|
@@ -366,6 +387,7 @@ pub const ThemeSel = struct {
pub const ThemeFile = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime enabled)
@@ -722,6 +744,7 @@ pub const Changelog = struct {
pub const EffectCode = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const enabled = capabilities.panel_transitions or capabilities.scene_shaders;
pub const output: OutputTraits = .{ .name = config.effect_code_buffer };
pub fn run(c: Ctx) void {
@@ -737,6 +760,7 @@ pub const EffectCode = struct {
pub const Pet = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const enabled = pardes.platform == .gui;
pub fn run(c: Ctx) void {
const name = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
@@ -758,6 +782,7 @@ pub const Mini = struct {
pub const Find = struct {
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true };
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
@@ -771,6 +796,7 @@ pub const Find = struct {
/// matches file CONTENTS under every pane's directory at once.
pub const Grep = struct {
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true, .locations = true };
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
@@ -940,6 +966,7 @@ pub const Subtypes = struct {
pub const Rename = struct {
pub const takes_arg = true;
+ pub const requires_arg = true;
pub fn run(c: Ctx) void {
const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (a.len > 0) return c.p.lspRequest(c.id, .rename, a);
@@ -949,6 +976,7 @@ pub const Rename = struct {
pub const WsSymbols = struct {
pub const takes_arg = true;
+ pub const requires_arg = true;
pub fn run(c: Ctx) void {
const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a);
@@ -971,6 +999,7 @@ pub const Lspwhy = struct {
pub const Peek = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const enabled = Board.enabled;
pub const output: OutputTraits = .{ .name = config.peek_buffer };
pub fn run(c: Ctx) void {
@@ -984,6 +1013,7 @@ pub const Peek = struct {
pub const Poke = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const enabled = Board.enabled;
pub fn run(c: Ctx) void {
if (comptime enabled) {
@@ -996,6 +1026,7 @@ pub const Poke = struct {
pub const Hexdump = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
+ pub const requires_arg = true;
pub const enabled = Board.enabled;
pub const output: OutputTraits = .{ .name = config.hexdump_buffer };
pub fn run(c: Ctx) void {
diff --git a/src/fs.zig b/src/fs.zig
index 339910a4..022a326a 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1257,9 +1257,13 @@ pub const Namespace = struct {
listeners: u16 = 0,
origin: u8 = 'K',
/// A ctl write is running builtins: one that would open a prompt for
- /// its argument refuses (`refused`), since nobody is at the prompt.
+ /// its argument refuses (`refused`), since nobody is at the prompt, and
+ /// the first error one reports (`failure`) fails the write, as acme's
+ /// ctl answers a command's error (editors/acme/xfid.c:700).
no_prompt: bool = false,
refused: bool = false,
+ failure: [96]u8 = undefined,
+ failure_len: u8 = 0,
/// A refusal that quotes the message it refuses, as Plan 9's cmderror
/// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig).
ename: [128]u8 = undefined,
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 945119ca..e47dec16 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -180,7 +180,8 @@ fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
/// Checks one line written to a ctl as a builtin of `scope` before any line
/// of the write runs: a word the registry knows, of this ctl's scope, given
-/// an argument only if it takes one, and for a setting a value it takes.
+/// an argument if and only if it takes or requires one, and for a setting a
+/// value it takes.
/// Answers the refusal, or null. Words are the builtins' own, capitalised
/// as on a tag; acme's lowercase verbs are the pane ctl's and alias none.
fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?Reply {
@@ -191,6 +192,7 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
if (builtins.registry.scope(b) != scope)
return refuse(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line);
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);
const setting = config.Runtime.find(word) orelse return null;
const takes = switch (setting.action) {
.theme => for (pardes.themes) |t| {
@@ -206,14 +208,19 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
}
/// Runs a checked control message at pane `id`, as a click on its word
-/// would; false when it would have opened a prompt for an argument, which
-/// nobody writing to a file is there to type.
-fn runBuiltin(p: *Pardes, id: usize, line: []const u8) bool {
+/// would. Answers the refusal when it would have opened a prompt for an
+/// argument, which nobody writing to a file is there to type, or when it
+/// reported an error: `Mount: AlreadyMounted "Mount peer /tmp/s"`.
+fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
p.fs.no_prompt = true;
p.fs.refused = false;
+ p.fs.failure_len = 0;
defer p.fs.no_prompt = false;
_ = exec_line.executeBuiltinLine(p, id, line);
- return !p.fs.refused;
+ if (p.fs.refused) return refuse(p, req, e_prompt, line);
+ if (p.fs.failure_len == 0) return null;
+ const refusal = refuse(p, req, p.fs.failure[0..p.fs.failure_len], line);
+ return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename };
}
const e_prompt = "control message needs its argument";
@@ -288,7 +295,7 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
continue;
}
if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
- if (!runBuiltin(p, p.active, line)) return tree.failText(req.tag, E.INVAL, e_prompt);
+ if (runBuiltin(p, req, p.active, line)) |refusal| return refusal;
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -404,8 +411,8 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
}
} else if (!apply) {
if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal;
- } else if (!runBuiltin(p, p.paneBySerial(serial).?, line)) {
- return tree.failText(req.tag, E.INVAL, e_prompt);
+ } else if (runBuiltin(p, req, p.paneBySerial(serial).?, line)) |refusal| {
+ return refusal;
}
}
}
@@ -542,13 +549,15 @@ test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the r
try testing.expectEqual(Status.ok, wr(p, ctl_node, "Msg from ctl\n").reply.status);
const pane = p.panes[p.paneBySerial(serial).?].?;
try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]);
- // One that would ask at a prompt for its argument fails instead.
- try testing.expectEqualStrings(e_prompt, wr(p, ctl_node, "Find").reply.ename);
+ // One that means nothing bare is refused before anything runs.
+ try testing.expectEqualStrings("wrong #args in control message \"Find\"", wr(p, ctl_node, "Find").reply.ename);
+ try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nMsg").errno());
+ try testing.expect(!std.mem.eql(u8, pane.msg[0..pane.msg_len], "first"));
try testing.expect(pane.prompt == .none);
try testing.expect(!p.fs.no_prompt);
// Save with no name on a scratch would ask for one: refused, not asked.
const other = try th.newPane(p);
- try testing.expectEqualStrings(e_prompt, wr(p, Node.of(other, .ctl), "Save").reply.ename);
+ try testing.expectEqualStrings(e_prompt ++ " \"Save\"", wr(p, Node.of(other, .ctl), "Save").reply.ename);
try testing.expect(p.panes[p.paneBySerial(other).?].?.prompt == .none);
// A line after the one that closed the pane has nowhere to run.
try testing.expectEqual(E.NOENT, wr(p, Node.of(other, .ctl), "Del\nMsg after").errno());
@@ -587,7 +596,18 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
try testing.expectEqualStrings("bad value in control message \"Theme no-such-theme\"", wr(p, root_ctl, "Theme no-such-theme").reply.ename);
try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename);
+ try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename);
+ // A bare required word fails the check, so the line before never runs.
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Verbose on\nMount").errno());
try testing.expect(!p.settings.verbose);
+ // A builtin that fails as it runs fails the write, quoting its error and
+ // its line; the lines before it have taken effect, as in acme.
+ const failed = wr(p, root_ctl, "Verbose on\nMount x\nVerbose off");
+ try testing.expectEqual(E.IO, failed.errno());
+ try testing.expectEqualStrings("Mount name dial: MissingArgument \"Mount x\"", failed.reply.ename);
+ try testing.expect(p.settings.verbose);
+ try testing.expect(!p.fs.no_prompt);
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off").reply.status);
}