diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-05 19:32:30 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 8aafc3fa24c7475a07259eb06cd5d217f510df98 (patch) | |
| tree | 62115e1ef1f15c57022060124c5ff934b778e169 | |
| parent | 30a44a55232b829866bc3554647de21929276d1a (diff) | |
| download | pardes-8aafc3fa24c7475a07259eb06cd5d217f510df98.tar.gz pardes-8aafc3fa24c7475a07259eb06cd5d217f510df98.zip | |
add a Shell builtin, default fish, with per-family prompt integration
| -rw-r--r-- | build.zig | 11 | ||||
| -rw-r--r-- | src/builtins.zig | 21 | ||||
| -rw-r--r-- | src/config.zig | 8 | ||||
| -rw-r--r-- | src/gui/gui.zig | 25 | ||||
| -rw-r--r-- | src/pardes.zig | 25 | ||||
| -rw-r--r-- | src/shell_bin.zig | 221 | ||||
| -rw-r--r-- | src/tty/tty.zig | 22 | ||||
| -rw-r--r-- | test/snapshot.zig | 9 | ||||
| -rw-r--r-- | test/snapshots/builtins.golden | 8 | ||||
| -rw-r--r-- | test/snapshots/shellset.golden | 63 | ||||
| -rw-r--r-- | test/snapshots/shellset.snap | 43 |
11 files changed, 422 insertions, 34 deletions
@@ -821,6 +821,17 @@ pub fn build(b: *std.Build) void { .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(temp_file_test).step); + // Resolving a shell binary and picking its prompt integration is the + // same shape: native-shell IO, no core imports, its own libc-linked + // module. (A test file the core merely re-exported would compile and + // silently never run — zig only collects tests from what it analyses.) + const shell_bin_test = b.addTest(.{ .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/shell_bin.zig"), + .link_libc = true, + }) }); + unit_step.dependOn(&b.addRunArtifact(shell_bin_test).step); // crt.zig is pure std — the mouse-mapping-vs-shader-formula test const crt_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, diff --git a/src/builtins.zig b/src/builtins.zig index aaeab5fa..9fa459f2 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -226,6 +226,27 @@ pub const Theme = struct { } }; +/// The binary a NEW terminal execs: `Shell fish`, `Shell zsh`, +/// `Shell /opt/homebrew/bin/bash`. A bare name is looked up on the shells' +/// side against the handful of directories a shell actually lives in — not +/// $PATH, because the lookup has to finish BEFORE the fork (a PATH search +/// allocates, and nothing between fork and exec may). +/// +/// Takes a NAME, so it has no leader path and none in the topbar, for the same +/// reason Theme has neither. Panes already open keep the shell they are +/// running; this is what the next one starts. +pub const Shell = struct { + pub fn run(c: Ctx) void { + const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n"); + // A name too long for the buffer is not silently truncated into a + // DIFFERENT binary — `/very/long/path/to/fish` clipped is a path that + // either does not exist or, worse, does. + if (want.len == 0 or want.len > c.p.shell_buf.len) return; + @memcpy(c.p.shell_buf[0..want.len], want); + c.p.shell_len = @intCast(want.len); + } +}; + /// ...and the list of what Theme takes, as a buffer you walk. Its rows are /// `Theme <name>` COMMANDS rather than locations, so n/N execute them instead /// of looking them (output_pane.Traits.executes) and stepping the list wears diff --git a/src/config.zig b/src/config.zig index a8e3639f..9451a2a7 100644 --- a/src/config.zig +++ b/src/config.zig @@ -128,6 +128,8 @@ pub const leader_path = paths: { // reason Look and Exec have none) .ThemeSel = "tt", .Theme = null, + // ...and `Shell` takes the name of a binary, so it has none either + .Shell = null, // the image toggles join the same `t` group; Palette takes `l` because // `p` is Petscii's and `c` is Colors'. .Petscii = "tp", @@ -430,6 +432,12 @@ test "wheel drift guard" { // ---- layout numbers that are taste ---- +/// What a terminal pane runs until someone says otherwise (the Shell builtin, +/// or a `Shell <name>` line in the config file). A bare NAME, resolved against +/// the usual bin directories at spawn time — so a machine without it falls +/// back rather than opening a pane that dies at exec. +pub const default_shell = "fish"; + /// the file pane's line-number gutter, in columns pub const PREFIX_W: u16 = 5; /// soft wrap (the Wrap builtin): the glyph a wrapped row ends with, in the one diff --git a/src/gui/gui.zig b/src/gui/gui.zig index a4883fa6..e1d5c86d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -28,6 +28,7 @@ const selection_pipe = @import("../selection_pipe.zig"); const is_emscripten = builtin.os.tag == .emscripten; const temp_file = if (is_emscripten) struct {} else @import("../temp_file.zig"); +const shell_bin = if (is_emscripten) struct {} else @import("../shell_bin.zig"); pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @@ -44,9 +45,6 @@ extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; -// absolute shell path per OS: execv must not search PATH (no allocation -// between fork and exec) -const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; // TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); @@ -1272,7 +1270,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { core.native_images = true; // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move) - writeFile(pardes.bash_rc); + writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc); + writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc); // macos: apple's bash 3.2 prints the zsh-deprecation banner into every // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); @@ -1775,7 +1774,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { if (opts.load_path == null) core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); - writeFile(pardes.bash_rc); + writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc); + writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc); // macos: apple's bash 3.2 prints the zsh-deprecation banner into every // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); @@ -2599,7 +2599,7 @@ fn drainEffects( cwd_buf[cwd.len] = 0; cwd_z = @ptrCast(&cwd_buf); } - const pt = forkShell(cwd_z, core.screen_h, core.screen_w); + const pt = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w); ptys[sp.pane] = pt; // report the pane's starting directory back to the core (tags) var lbuf: [1024]u8 = undefined; @@ -2678,14 +2678,17 @@ fn drainEffects( }; } -fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { +fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { var master: c_int = undefined; + // resolved BEFORE the fork, into this frame, which the child inherits: + // nothing between fork and exec may allocate, and a PATH search would + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const spawn = shell_bin.resolve(bin, &path_buf); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { if (cwd) |cd| _ = chdir(cd); - const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; - _ = execv(bash_path, &argv); + _ = execv(spawn.path, &spawn.argv); _exit(127); } return .{ .fd = master, .pid = pid }; @@ -4281,8 +4284,8 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { return std.fmt.parseInt(u16, raw, 10) catch null; } -fn writeFile(data: []const u8) void { - const fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); +fn writeFile(path: [*:0]const u8, data: []const u8) void { + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return; defer _ = libc.close(fd); writeFd(fd, data); diff --git a/src/pardes.zig b/src/pardes.zig index 61950562..08536544 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -2232,14 +2232,6 @@ fn pathUnder(a: []const u8, b: []const u8) bool { } const tutor_text = @embedFile("tutor.txt"); -const rc_path = "/tmp/pardes-osc133.bash"; -pub const bash_rc = - \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" - \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' - \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" - \\trap 'printf "\e]133;C\a"' DEBUG - \\ -; // ---- theme ---- @@ -3960,6 +3952,14 @@ pub const Pardes = struct { /// its first theme. No frame is rendered in that interval. animate_theme_changes: bool = false, colors_on: bool = true, + /// The binary a NEW terminal execs (the Shell builtin). Inline and fixed + /// like every other short string the core keeps, and `0` means "the + /// default" rather than a copy of it — so there is no constructor to + /// remember and config.default_shell stays the single place the default is + /// written. Existing panes are not touched: a shell you are already in is + /// a process, not a setting. + shell_buf: [255]u8 = undefined, + shell_len: u8 = 0, /// soft line breaks in file panes (the Wrap builtin). ON by default: a line /// running off the right edge is text you cannot read without scrolling /// sideways for it, and the reason to reach for the toggle is the rare @@ -4322,6 +4322,13 @@ pub const Pardes = struct { return true; } + /// What a shell should exec for the next terminal — a bare name to be + /// looked up, or an absolute path to be used as it stands. The resolving + /// is the shells' half: the core has no filesystem to ask. + pub fn shellBin(p: *const Pardes) []const u8 { + return if (p.shell_len == 0) config.default_shell else p.shell_buf[0..p.shell_len]; + } + /// Post the transient message on `id`'s last row. Called by a SHELL once /// the IO it narrates has actually happened, exactly as openNewFile above /// completes a `new_file` effect: the core neither writes files nor owns a @@ -11033,7 +11040,7 @@ pub const Pardes = struct { /// something that has somewhere to put it. fn takesArg(b: Builtin) bool { switch (b) { - .Restore, .Find, .Grep, .Rename, .WsSymbols, .Theme, .Look, .Exec => return true, + .Restore, .Find, .Grep, .Rename, .WsSymbols, .Theme, .Shell, .Look, .Exec => return true, // Font is a BRANCH and not another name in that prong for the // reason output_pane.traits has the same shape: it is not in the // enum at all off the gui, and a comptime-false `if` is the one diff --git a/src/shell_bin.zig b/src/shell_bin.zig new file mode 100644 index 00000000..ecd80937 --- /dev/null +++ b/src/shell_bin.zig @@ -0,0 +1,221 @@ +//! Turning the name of a shell into something a freshly forked child can exec, +//! and into the argv that hands that shell its prompt marks. +//! +//! Native-shell side, like temp_file.zig and message.zig, and for the same +//! reason: it touches the filesystem, and the core does not. The core carries +//! only the NAME (Pardes.shellBin, what the Shell builtin was given); which +//! family that is, what to write for it, where to write it and where the +//! binary actually lives all live here, and both frontends call it rather than +//! keeping a copy each. Nothing here imports the core, which is also what lets +//! it be its own std-only test module. +//! +//! ALL OF THIS RUNS IN THE PARENT. Between fork and exec a process may not +//! allocate, and a $PATH search does — which is the same reason the exec is +//! `execv` on an absolute path and never `execvp`. So the lookup is a handful +//! of `access` calls over the directories a shell actually lives in, done +//! before the fork, into a caller buffer that the child then inherits through +//! its copy of the stack. +const std = @import("std"); +const builtin = @import("builtin"); + +const libc = std.c; +const X_OK: c_int = 1; + +/// Prompt integration, per shell FAMILY rather than per binary: pardes hides +/// prompt rows, moves the cursor by clicking one, and tells a command's output +/// from the line that asked for it, and all three read the OSC 133 marks a +/// shell has to be talked into emitting. Every family needs different words +/// for the same four marks and a different way to be handed them, so the +/// binary a pane is about to exec picks one of these and there is nothing to +/// configure. +pub const ShellRc = enum { bash, fish, none }; + +/// Which family a shell binary belongs to, by the BASENAME's prefix — the +/// whole heuristic. A prefix and not an exact match because a real system +/// spells them `bash`, `/usr/bin/bash`, `bash-5.2`, `fish-3.7`, and pinning +/// exact names would mean a list to maintain against other people's packaging. +/// It costs a false positive on a program called `fishing`, which is a shell +/// nobody has. +/// +/// `none` is not a failure: it execs the binary plain and the pane works, it +/// just has no prompt marks, so prompts are not hidden and a click on one does +/// not move the shell's cursor. Everything else about the pane is unaffected. +/// +/// ponytail: two families and a fallback. zsh is the obvious third and is NOT +/// here because it is shaped differently — it has no `--rcfile`, so it needs a +/// whole ZDOTDIR directory staged with a .zshrc that re-sources the user's, +/// plus an env var set before exec. Add it when someone runs zsh in pardes and +/// misses prompt hiding; the rc text itself is four lines (precmd/preexec). +pub fn shellRc(bin: []const u8) ShellRc { + const slash = std.mem.lastIndexOfScalar(u8, bin, '/'); + const base = if (slash) |s| bin[s + 1 ..] else bin; + if (std.mem.startsWith(u8, base, "bash")) return .bash; + if (std.mem.startsWith(u8, base, "fish")) return .fish; + return .none; +} + +pub const bash_rc_path = "/tmp/pardes-osc133.bash"; +pub const fish_rc_path = "/tmp/pardes-osc133.fish"; + +pub const bash_rc = + \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" + \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' + \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + \\trap 'printf "\e]133;C\a"' DEBUG + \\ +; + +/// fish is handed this with `-C`, which runs AFTER config.fish — and it has to, +/// because the first thing it does is copy the user's own `fish_prompt` to call +/// it from the middle of ours. Loaded any earlier it would copy the default and +/// silently replace whatever the user actually configured. +/// +/// The other half is why there is no `source ~/.config/fish/config.fish` line +/// the way the bash rc sources .bashrc: bash is being started with `--rcfile`, +/// which REPLACES its startup file, so the rc has to put it back. `-C` adds to +/// fish's startup instead of standing in for it. +/// +/// C and D come off fish's own `fish_preexec`/`fish_postexec` events rather +/// than being spliced into the prompt, which is what bash's DEBUG trap is +/// working around. +pub const fish_rc = + \\functions -c fish_prompt __pardes_user_prompt + \\function fish_prompt + \\ printf '\e]133;A;cl=line\a' + \\ __pardes_user_prompt + \\ printf '\e]133;B\a' + \\end + \\function __pardes_preexec --on-event fish_preexec + \\ printf '\e]133;C\a' + \\end + \\function __pardes_postexec --on-event fish_postexec + \\ printf '\e]133;D\a' + \\end + \\ +; + +test "shell family is the basename's prefix, and anything else runs unadorned" { + try std.testing.expectEqual(ShellRc.fish, shellRc("fish")); + try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/bin/fish")); + try std.testing.expectEqual(ShellRc.fish, shellRc("/opt/homebrew/bin/fish")); + try std.testing.expectEqual(ShellRc.bash, shellRc("bash")); + try std.testing.expectEqual(ShellRc.bash, shellRc("/bin/bash")); + // packaged with a version on the end, which is why this is a prefix + try std.testing.expectEqual(ShellRc.bash, shellRc("/usr/bin/bash-5.2")); + try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/local/bin/fish-3.7")); + // a directory that merely CONTAINS the word is not the shell's name + try std.testing.expectEqual(ShellRc.none, shellRc("/opt/fish/bin/nu")); + // no marks, still a shell + try std.testing.expectEqual(ShellRc.none, shellRc("/usr/bin/zsh")); + try std.testing.expectEqual(ShellRc.none, shellRc("/bin/sh")); + try std.testing.expectEqual(ShellRc.none, shellRc("nu")); + try std.testing.expectEqual(ShellRc.none, shellRc("")); +} + +/// The directories a shell binary is actually installed in. Not $PATH: see the +/// header. `/opt/homebrew` and `/opt/local` are where a mac keeps the shells +/// that did not ship with it, which is every shell anyone chooses on purpose. +const bin_dirs = [_][]const u8{ + "/usr/bin/", + "/bin/", + "/usr/local/bin/", + "/opt/homebrew/bin/", + "/opt/local/bin/", + "/usr/sbin/", +}; + +/// Last resorts, in order, when the configured shell is not installed: the +/// shell pardes used to hardcode, then the one POSIX says exists. A pane that +/// opens with the wrong shell beats a pane whose child dies at exec and shows +/// nothing but an immediate EOF. +const fallbacks = [_][]const u8{ + if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash", + "/bin/sh", +}; + +pub const Spawn = struct { + path: [*:0]const u8, + /// argv for execv. Shorter forms stop at their first null, which is what + /// execv reads anyway, so one width covers all three families. + argv: [4:null]?[*:0]const u8, +}; + +/// `bin` is whatever the Shell builtin was given — a bare name to look up, or +/// a path (anything with a `/`) to take at its word. `buf` holds the resolved +/// path for as long as the returned Spawn is used, which for a caller that is +/// about to fork means: until the child execs. +pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) Spawn { + const path = find(bin, buf) orelse fallback(buf); + // the family comes off the path that will ACTUALLY be executed, not the + // name that was asked for — `Shell sh` on a system where that is a symlink + // to bash still has no `--rcfile` promise attached to it, and a resolved + // /usr/bin/fish reads as fish whether it was reached by name or by path + const marks: [2]?[*:0]const u8 = switch (shellRc(std.mem.span(path))) { + .bash => .{ "--rcfile", bash_rc_path }, + // -C runs AFTER config.fish, which is the whole point (see fish_rc) + .fish => .{ "-C", "source " ++ fish_rc_path }, + .none => .{ null, null }, + }; + return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } }; +} + +fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { + if (bin.len == 0 or bin.len + 1 > buf.len) return null; + if (std.mem.indexOfScalar(u8, bin, '/') != null) { + @memcpy(buf[0..bin.len], bin); + buf[bin.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + return if (libc.access(p, X_OK) == 0) p else null; + } + for (bin_dirs) |dir| { + if (dir.len + bin.len + 1 > buf.len) continue; + @memcpy(buf[0..dir.len], dir); + @memcpy(buf[dir.len..][0..bin.len], bin); + buf[dir.len + bin.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + if (libc.access(p, X_OK) == 0) return p; + } + return null; +} + +fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 { + for (fallbacks) |f| { + @memcpy(buf[0..f.len], f); + buf[f.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + if (libc.access(p, X_OK) == 0) return p; + } + // nothing executable anywhere we know to look: exec will fail and the pane + // will show an immediate EOF, which is the honest report of that machine. + // buf already holds the last candidate, NUL and all. + return @ptrCast(buf); +} + +test "a path is taken at its word, a name is looked up, and both pick their own marks" { + if (builtin.os.tag == .windows) return; + var buf: [std.fs.max_path_bytes]u8 = undefined; + + // /bin/sh exists on every unix this builds for and is in no family, so it + // pins the resolve-by-path arm AND the unadorned argv + const sh = resolve("/bin/sh", &buf); + try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path)); + try std.testing.expect(sh.argv[1] == null); + + // a name with no slash is searched for; whatever it resolves to, it is a + // bash and so carries --rcfile pointing at the rc the shells write + const bash = resolve("bash", &buf); + try std.testing.expect(shellRc(std.mem.span(bash.path)) == .bash); + try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?)); + try std.testing.expectEqualStrings(bash_rc_path, std.mem.span(bash.argv[2].?)); + + // nothing is installed under this name, so the fallback answers — and the + // fallback is a real executable, not the name that failed + const missing = resolve("zznosuchshell", &buf); + try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path))); + try std.testing.expect(libc.access(missing.path, X_OK) == 0); + + // an absolute path that does not exist falls back too, rather than being + // handed to exec to fail on + const gone = resolve("/zz/no/such/shell", &buf); + try std.testing.expect(libc.access(gone.path, X_OK) == 0); +} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index b5b4ff75..22449719 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -12,6 +12,7 @@ const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); const temp_file = @import("../temp_file.zig"); +const shell_bin = @import("../shell_bin.zig"); const message = @import("../message.zig"); const selection_pipe = @import("../selection_pipe.zig"); @@ -21,9 +22,6 @@ extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -// absolute shell path per OS: execv must not search PATH (no allocation -// between fork and exec) -const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; // TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); @@ -379,7 +377,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { defer core.deinit(); // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move) - writeFile(pardes.bash_rc); + writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc); + writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc); // macos: apple's bash 3.2 prints the zsh-deprecation banner into every // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); @@ -854,7 +853,7 @@ fn drainEffects( cwd_buf[cwd.len] = 0; cwd_z = @ptrCast(&cwd_buf); } - const child = forkShell(cwd_z, core.screen_h, core.screen_w); + const child = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w); ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; // report the pane's starting directory back to the core (tags) var lbuf: [1024]u8 = undefined; @@ -1105,8 +1104,12 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } -fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } { +fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } { var master: c_int = undefined; + // resolved BEFORE the fork, into this frame, which the child inherits: + // nothing between fork and exec may allocate, and a PATH search would + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const spawn = shell_bin.resolve(bin, &path_buf); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { @@ -1117,8 +1120,7 @@ fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.Fi posix.sigaddset(&set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); if (cwd) |c| _ = chdir(c); - const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; - _ = execv(bash_path, &argv); + _ = execv(spawn.path, &spawn.argv); _exit(127); } return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; @@ -1203,8 +1205,8 @@ fn vaxisColor(c: pardes.Color) vaxis.Color { }; } -fn writeFile(data: []const u8) void { - const fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); +fn writeFile(path: [*:0]const u8, data: []const u8) void { + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return; defer _ = libc.close(fd); writeFd(fd, data); diff --git a/test/snapshot.zig b/test/snapshot.zig index 39350bee..36a5b20b 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -311,6 +311,15 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const // XDG directory that points outside this per-snapshot fake home. const config_home = try std.fmt.allocPrintSentinel(arena, "{s}/.config", .{home}, 0); _ = setenv("XDG_CONFIG_HOME", config_home, 1); + // ...and it is not empty: the DEFAULT shell is fish, whose prompt carries + // a hostname and whose greeting carries a version, so a golden taken + // against it would be a golden for this machine. Every script here runs + // bash — the .bashrc above pins its prompt to `$ ` — and the fish path is + // covered by shell_bin.zig's unit tests instead. This is an ordinary + // config file going through the ordinary startup-config path, so it also + // proves `Shell <name>` is dispatchable from one. + try mkdir(arena, config_home, false); + try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/pardes", .{config_home}, 0), "Shell bash\n"); _ = setenv("TERM", "xterm-256color", 1); _ = setenv("LC_ALL", "C", 1); // a wall clock cannot live in a golden; everything that would print one diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden index 36a7b2d9..08c701d7 100644 --- a/test/snapshots/builtins.golden +++ b/test/snapshots/builtins.golden @@ -153,7 +153,6 @@ | | | /tmp/pardes-snap/builtins/cwd/+Help New Del -| 24 SPC l i Lspinfo | 25 SPC l k Hover | 26 SPC l r Rename | 27 SPC l s Symbols @@ -180,7 +179,8 @@ | 48 Look enter, right-click | 49 Exec tab, middle-click | 50 Theme -| 51 +| 51 Shell +| 52 == snap not-a-picker grid=120x60 cursor=7,58 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del @@ -214,7 +214,6 @@ | | | /tmp/pardes-snap/builtins/cwd/+Help New Del -| 24 SPC l i Lspinfo | 25 SPC l k Hover | 26 SPC l r Rename | 27 SPC l s Symbols @@ -241,7 +240,8 @@ | 48 Look enter, right-click | 49 Exec tab, middle-click | 50 Theme -| 51 +| 51 Shell +| 52 == snap window-group grid=120x60 cursor=7,32 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del diff --git a/test/snapshots/shellset.golden b/test/snapshots/shellset.golden new file mode 100644 index 00000000..330d21dc --- /dev/null +++ b/test/snapshots/shellset.golden @@ -0,0 +1,63 @@ +== snap boot grid=90x20 cursor=7,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /tmp/pardes-snap/shellset/cwd/cmd.txt Save New Del +| 1 Shell zznosuchshell +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap builtin-ran grid=90x20 cursor=25,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /tmp/pardes-snap/shellset/cwd/cmd.txt Save New Del +| 1 Shell zznosuchshell +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap fallback-shell grid=90x20 cursor=49,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /tmp/pardes-snap/shellset/cwd/cmd.txt Save /tmp/pardes-snap/shellset/cwd New Del +| 1 Shell zznosuchshell +| cmd.txt zmarkfile.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/shellset.snap b/test/snapshots/shellset.snap new file mode 100644 index 00000000..193d8432 --- /dev/null +++ b/test/snapshots/shellset.snap @@ -0,0 +1,43 @@ +# The Shell builtin names the binary the NEXT terminal execs. What is pinned +# here is the case that has to hold on a machine you did not set up: the name +# resolves to nothing, and the pane opens anyway. +# +# That is not a corner — it is the DEFAULT's failure mode. pardes ships +# `Shell fish` as the default, and a box without fish must still get a working +# shell rather than a pane whose child dies at exec and shows one EOF. So the +# lookup falls back, and this is the proof: a name nothing resolves to, and the +# new pane still greets and lists like any other. +# +# The rest of the feature is unit-tested rather than pinned here, on purpose. +# Which family a binary belongs to, what argv each one gets and where the +# lookup searches are in shell_bin.zig's own tests; the fish end of it cannot +# be a golden at all, because fish's prompt carries a hostname and its greeting +# a version number. (Every script in this suite runs bash, pinned through an +# ordinary config file the harness writes — see snapshot.zig.) +file cmd.txt Shell zznosuchshell +file zmarkfile.txt x +start 20 90 cmd.txt +wait 8000 Shell zznosuchshell +stable 700 20000 +snap boot +# execute the line the way you would run any command: select it, Tab. Nothing +# on any machine is installed under that name, so the resolver runs out of +# directories. (A file pane, so the line executed is the line you can see — +# typing into a terminal's overlay would leave `gg`/`x` selecting its output.) +key g g +key x +stable 400 5000 +key tab +stable 700 15000 +# NOTHING happened on screen, and that is the assertion: a name the builtin +# parser does not know is a command line, so it would have forked a terminal +# for this directory and printed `Shell: command not found` into it (that is +# cmdword.snap's contract). One pane, no error — `Shell` was consumed as a +# builtin and its argument went with it. +snap builtin-ran +# a new terminal, which asks for a shell that is not there. It gets the +# fallback: the pane greets, `ls` runs, and its output is on screen. +key space c n +wait 15000 zmarkfile.txt +stable 700 20000 +snap fallback-shell |
