summaryrefslogtreecommitdiff
path: root/src/builtins.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/builtins.zig')
-rw-r--r--src/builtins.zig213
1 files changed, 190 insertions, 23 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 1864086f..23d853c8 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -44,6 +44,10 @@ pub const Ctx = struct {
id: usize,
txt: []const u8,
arg: ?[]const u8,
+ /// Made by a gesture on the pane's body -- a middle click, the execute
+ /// key -- as acme's execute() asks of its Text (exec.c:122,169): an
+ /// Exec from a body may be a bound REPL's (exec.zig routeToRepl).
+ from_body: bool = false,
};
fn isEnabled(comptime T: type) bool {
@@ -145,6 +149,16 @@ pub const registry = struct {
return true;
}
+ /// Whether the word takes its argument joined by a `+` as one word a
+ /// tag can hold and a click take whole (`Tty+fish`). Only a builtin
+ /// that says `pub const plus_arg = true;`: others have arguments a `+`
+ /// may be in (`Dump+2024.zon` is a name, not Dump's argument).
+ pub fn plusArg(b: Builtin()) bool {
+ inline for (manualBuiltinList(), 0..) |T, i|
+ if (@intFromEnum(b) == i) return @hasDecl(T, "plus_arg") and T.plus_arg;
+ return false;
+ }
+
/// 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
@@ -226,16 +240,57 @@ test "Exit asks once about unsaved text, and quits when asked again" {
try std.testing.expect(p.quit);
}
+/// A dump file that exists, for a Restore to get past reading it.
+fn testDump(buf: []u8) ![]const u8 {
+ var tmp = std.testing.tmpDir(.{});
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "d.zon", .data = ".{}" });
+ var dir: [4096]u8 = undefined;
+ const at = dir[0..try tmp.dir.realPath(std.testing.io, &dir)];
+ return std.fmt.bufPrint(buf, "Restore {s}/d.zon", .{at});
+}
+
test "Restore asks about unsaved text as Exit does, and restores when asked again" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
- try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /tmp/some.dump.zon"));
+ var line_buf: [4200]u8 = undefined;
+ const line = try testDump(&line_buf);
+ try std.testing.expect(p.executeBuiltinLine(p.active, line));
try std.testing.expect(p.restore_req == null);
try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Restore again to discard)"));
- try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /tmp/some.dump.zon"));
- try std.testing.expectEqualStrings("/tmp/some.dump.zon", p.restore_req.?);
+ try std.testing.expect(p.executeBuiltinLine(p.active, line));
+ try std.testing.expectEqualStrings(line["Restore ".len..], p.restore_req.?);
+}
+
+test "a Restore of a dump that cannot be read says so, before the unsaved-text warning" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("saved\n");
+ pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ p.fs.no_prompt = true;
+ defer p.fs.no_prompt = false;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /nope/missing.zon"));
+ try std.testing.expect(p.restore_req == null);
+ try std.testing.expectEqualStrings("Restore: /nope/missing.zon: no such file", p.fs.failure[0..p.fs.failure_len]);
+ // No warning spent: the Exit after it still asks.
+ try std.testing.expect(pane.discard_warned == null);
+}
+
+test "a Restore's warning is not an Exit's: each word is warned on its own" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("saved\n");
+ pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ var line_buf: [4200]u8 = undefined;
+ try std.testing.expect(p.executeBuiltinLine(p.active, try testDump(&line_buf)));
+ try std.testing.expect(p.restore_req == null);
+ // The Restore warned; an Exit after it has not been, and asks.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(!p.quit);
+ try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Exit again to discard)"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(p.quit);
}
test "Kill stops the commands pardes typed, bare all and by name those that match, and never quits" {
@@ -264,8 +319,9 @@ test "Kill stops the commands pardes typed, bare all and by name those that matc
}
};
p.host = .{ .vtable = &killed.vtable };
- // Nothing running: nothing to stop, and never the shell.
+ // Nothing running: nothing to stop, and never the shell; said.
try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill"));
+ try std.testing.expectEqualStrings("Kill: nothing running", p.panes[term].?.msg[0..p.panes[term].?.msg_len]);
_ = exec.execute(p, term, "make -j8");
while (p.nextEffect()) |_| {}
// Typed but not started yet (no C mark): not running.
@@ -346,7 +402,7 @@ pub const Exec = struct {
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
- _ = exec.execute(c.p, c.id, c.arg orelse return);
+ _ = exec.executeFrom(c.p, c.id, c.arg orelse return, c.from_body);
}
};
@@ -356,21 +412,22 @@ pub const Exec = struct {
/// what would throw unsaved text away: each pane holding some nobody has
/// been told about says so, `<name>: Modified (Exit again to discard)`,
/// naming the word asking, since repeating it throws the text away, and is
-/// then clean for the
-/// asking, as winclean clears `dirty`; so the same word again, with
-/// nothing edited since, goes ahead. A scratch of under 100 bytes is no
-/// loss worth the question, as winclean does not ask for one.
-fn warnModified(c: Ctx, asking: []const u8) bool {
+/// then clean for the asking, as winclean clears `dirty`; so the same word
+/// again, with nothing edited since, goes ahead. Only the same word: a
+/// Restore a script was refused does not spend the warning an Exit owes
+/// the person at the keyboard. A scratch of under 100 bytes is no loss
+/// worth the question, as winclean does not ask for one.
+fn warnModified(c: Ctx, asking: Pane.Discarding) bool {
var modified = false;
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
const f = pane.file orelse continue;
if (f.revision == f.saved_revision or !panes.Output.fileTraits(f.output).saves) continue;
if (f.output != null and f.content.len < 100) continue;
- if (pane.discard_warned == f.revision) continue;
- pane.discard_warned = f.revision;
+ if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == asking) continue;
+ pane.discard_warned = .{ .revision = f.revision, .by = asking };
var buf: [limits.host_path_cap + 48]u8 = undefined;
- c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ f.path, asking }) catch f.path);
+ c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ f.path, @tagName(asking) }) catch f.path);
modified = true;
}
return modified;
@@ -381,7 +438,7 @@ fn warnModified(c: Ctx, asking: []const u8) bool {
pub const Exit = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
- if (warnModified(c, "Exit")) return;
+ if (warnModified(c, .Exit)) return;
c.p.quit = true;
c.p.emit(.quit);
}
@@ -403,6 +460,21 @@ pub const Kill = struct {
var jobless = false;
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
+ // A command pane's child is the command: the whole of its
+ // process group goes, as acme's Kill ends the process.
+ if (pane.command) |line| if (!pane.command_done) {
+ const said = std.mem.trim(u8, line, " \t");
+ const first = said[0 .. std.mem.indexOfAny(u8, said, " \t") orelse said.len];
+ if (names.len > 0) {
+ var words = std.mem.tokenizeAny(u8, names, " \t");
+ while (words.next()) |w| {
+ if (std.mem.eql(u8, w, first)) break;
+ } else continue;
+ }
+ c.p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .serial = pane.serial, .sig = .term } });
+ stopped = true;
+ continue;
+ };
if (!panes.Terminal.commandRunning(pane)) continue;
const sent = pane.sent_command.?;
if (names.len > 0) {
@@ -417,7 +489,10 @@ pub const Kill = struct {
}
if (stopped) return;
if (jobless) return c.p.reportFailure(c.id, "Kill: no job to signal (its shell runs it without job control)");
- if (names.len > 0) c.p.reportError(c.id, "Kill", error.NoSuchCommand);
+ if (names.len > 0)
+ c.p.reportFailure(c.id, "Kill: no running command has that first word")
+ else // said, but no failure: a script's Kill has nothing left to do
+ c.p.setMessage(c.id, "Kill: nothing running");
}
};
@@ -432,11 +507,27 @@ pub const Restore = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
pub fn run(c: Ctx) void {
- const path = c.arg orelse (c.p.last_dump orelse return);
- if (path.len > c.p.restore_buf.len) return;
+ const path = c.arg orelse (c.p.last_dump orelse return c.p.reportFailure(c.id, "Restore: no dump yet; name one"));
+ if (path.len > c.p.restore_buf.len) return c.p.reportFailure(c.id, "Restore: that name is too long");
+ // A dump that cannot be read is said now, and fails a ctl write: the
+ // host reads it only after this step, where nobody hears. First,
+ // so a Restore that could never happen spends no warning.
+ if (comptime pardes.hosted) {
+ const bytes = @import("fs.zig").readRestore(c.p.gpa, path, c.p.settings.dump_dir.get()) catch |err| {
+ var buf: [limits.host_path_cap + 64]u8 = undefined;
+ const why = switch (err) {
+ error.FileNotFound => "no such file",
+ error.PermissionDenied => "permission denied",
+ error.IsDirectory => "a directory, not a dump",
+ else => @errorName(err),
+ };
+ return c.p.reportFailure(c.id, std.fmt.bufPrint(&buf, "Restore: {s}: {s}", .{ path, why }) catch "Restore: cannot read the dump");
+ };
+ c.p.gpa.free(bytes);
+ }
// acme's Load adds a dump's windows to the ones there; a Restore
// replaces them all, so it asks what Exit asks first.
- if (warnModified(c, "Restore")) return;
+ if (warnModified(c, .Restore)) return;
@memcpy(c.p.restore_buf[0..path.len], path);
c.p.restore_req = c.p.restore_buf[0..path.len];
}
@@ -703,7 +794,8 @@ pub const Newcol = struct {
};
/// Close the pane. `Del k` gives its rows to the open pane above it and
-/// `Del j` to the one below, each falling back to the other side.
+/// `Del j` to the one below, each falling back to the other side. Focus on
+/// it goes with its rows.
pub const Del = struct {
pub const takes_arg = true;
pub fn run(c: Ctx) void {
@@ -716,7 +808,7 @@ pub const Del = struct {
else if (std.mem.eql(u8, side, "j"))
below orelse above
else if (side.len > 0)
- return c.p.reportError(c.id, "Del k|j", error.InvalidArgument)
+ return c.p.reportFailure(c.id, "Del: takes k, j or nothing")
else
null;
// Someone at the keyboard with a real choice to make is asked, on the
@@ -734,8 +826,6 @@ pub const Del = struct {
return;
}
c.p.removePane(c.id, to) catch |err| return c.p.reportError(c.id, "close", err);
- // Del k|j is a direction: focus lands where the rows went.
- if (to) |gainer| c.p.active = gainer;
}
};
@@ -787,9 +877,24 @@ pub const DelBelow = struct {
/// A shell in the calling pane's directory, raw from the first frame. On every
/// pane's tagline: the fast path from wherever you are to a prompt there.
+/// A terminal below the caller in its directory: the configured shell, or
+/// the one named, `Tty fish`, as a terminal's own tag word `Tty+fish` says.
pub const Tty = struct {
+ pub const takes_arg = true;
+ pub const plus_arg = true;
pub fn run(c: Ctx) void {
- exec.spawnTty(c.p, c.id);
+ const arg = std.mem.trim(u8, c.arg orelse "", " \t");
+ // A shell that is not there is said, where the host would quietly
+ // start its fallback in its place.
+ if (comptime pardes.hosted) if (arg.len > 0) {
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ if (@import("host_io.zig").Shell.find(arg, &buf) == null) {
+ var said: [320]u8 = undefined;
+ return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Tty: no shell \"{s}\" (a name on the usual paths, or a path to one)", .{arg[0..@min(arg.len, 200)]}) catch "Tty: no such shell");
+ }
+ };
+ const pane = exec.spawnTty(c.p, c.id) orelse return;
+ if (arg.len > 0) pane.shell = c.p.gpa.dupe(u8, arg) catch null;
}
};
@@ -802,10 +907,72 @@ pub const Tty9p = struct {
};
/// Fold the active pane's column into the one on its right, keeping its panes.
+/// Binds this terminal as a language's REPL, by the language's name as the
+/// syntax table has it: an exec made on the body of a file in that
+/// language -- a middle click, the execute key, a single word like `ls`
+/// as much as a selection -- and one made on this terminal's own body, is
+/// typed into it (bracketed paste if its program asked for that, else line
+/// by line, then Enter) instead of run. `Repl python` binds (again, it is
+/// already); `Repl -` unbinds; `Repl` bare says what it is bound as. With
+/// several bound for a language, the exec asks which.
+/// Still commands, whatever is bound: a word in a tag, `Exec <text>` run by
+/// name (typed, a 2-1 chord onto Exec, a ctl line), and a command word
+/// @`cmd` in the text, looked at or clicked.
+pub const Repl = struct {
+ pub const takes_arg = true;
+ pub fn run(c: Ctx) void {
+ const pane = c.pane;
+ // A terminal with its emulator -- not a document pane -- and not
+ // a command pane whose command, the REPL it would be, is done.
+ if (!pane.isTerminal() or pane.terminal == null) return c.p.reportFailure(c.id, "Repl: only a terminal is a REPL");
+ if (pane.command != null and pane.command_done) return c.p.reportFailure(c.id, "Repl: its command is done; a REPL is a running program");
+ const arg = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ var buf: [320]u8 = undefined;
+ var idbuf: [48]u8 = undefined;
+ if (arg.len == 0) {
+ if (pane.repl == null) return c.p.setMessage(c.id, "Repl: not bound");
+ return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl");
+ }
+ if (std.mem.eql(u8, arg, "-")) {
+ pane.repl = null;
+ return c.p.setMessage(c.id, "Repl: unbound");
+ }
+ const manifest = @import("grammar_manifest.zig");
+ const grammars = manifest.all;
+ // Named as a code fence names it: `py`, `Python`, `sh`.
+ const named: ?u8 = if (manifest.byName(arg)) |i| if (grammars[i].exts.len > 0) @intCast(i) else null else null;
+ const lang: u8 = named orelse {
+ var w = std.Io.Writer.fixed(&buf);
+ w.print("Repl: no language \"{s}\"; - or one of:", .{@import("Messages.zig").clip(arg, 24)}) catch {};
+ for (grammars) |g| if (g.exts.len > 0) w.print(" {s}", .{g.name}) catch break;
+ return c.p.reportFailure(c.id, @import("Messages.zig").clip(w.buffered(), 256));
+ };
+ if (pane.repl) |r| if (r.lang == lang)
+ return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl");
+ // The lowest letter no other REPL of the language has: freed ones
+ // come back.
+ var letter: u8 = 'a';
+ while (letter <= 'z') : (letter += 1) {
+ const taken = for (c.p.panes) |slot| {
+ const other = slot orelse continue;
+ if (other != pane) if (other.repl) |r| if (r.lang == lang and r.letter == letter) break true;
+ } else false;
+ if (!taken) break;
+ }
+ if (letter > 'z') return c.p.reportFailure(c.id, "Repl: every letter of the language is taken");
+ pane.repl = .{ .lang = lang, .letter = letter };
+ pane.repl_warned = false;
+ c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl");
+ }
+};
+
/// The horizontal mirror of the vertical stacking `New` does.
pub const Joincol = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
+ // The keyboard's column joins the one to its right; the last has none.
+ const at = layout.findPane(c.p, c.p.active) orelse return;
+ if (at.col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right");
layout.joinCol(c.p);
}
};