summaryrefslogtreecommitdiff
path: root/src/shell_bin.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-05 19:32:30 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit8aafc3fa24c7475a07259eb06cd5d217f510df98 (patch)
tree62115e1ef1f15c57022060124c5ff934b778e169 /src/shell_bin.zig
parent30a44a55232b829866bc3554647de21929276d1a (diff)
downloadpardes-8aafc3fa24c7475a07259eb06cd5d217f510df98.tar.gz
pardes-8aafc3fa24c7475a07259eb06cd5d217f510df98.zip
add a Shell builtin, default fish, with per-family prompt integration
Diffstat (limited to 'src/shell_bin.zig')
-rw-r--r--src/shell_bin.zig221
1 files changed, 221 insertions, 0 deletions
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);
+}