1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
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);
}
|