diff options
Diffstat (limited to 'src/builtins.zig')
| -rw-r--r-- | src/builtins.zig | 213 |
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); } }; |
