shell-integration: automatic bash integration

This change adds automatic bash shell detection and integration.

Unlike our other shell integrations, bash doesn't provide a built-in
mechanism for injecting our ghostty.bash script into the new shell
environment.

Instead, we start bash in POSIX mode and use the ENV environment
variable to load our integration script, and the rest of the bash
startup sequence becomes the responsibility of our script to emulate
(along with disabling POSIX mode).
This commit is contained in:
Jon Parise
2024-05-05 13:24:09 -07:00
parent 2e2d924353
commit 73b3560e62
4 changed files with 421 additions and 54 deletions

View File

@ -408,7 +408,7 @@ palette: Palette = .{},
/// Ghostty does not do any shell command parsing.
///
/// If you're using the `ghostty` CLI there is also a shortcut to run a command
/// with argumens directly: you can use the `-e` flag. For example: `ghostty -e
/// with arguments directly: you can use the `-e` flag. For example: `ghostty -e
/// fish --with --custom --args`.
command: ?[]const u8 = null,
@ -841,7 +841,7 @@ keybind: Keybinds = .{},
///
/// * `detect` - Detect the shell based on the filename.
///
/// * `fish`, `zsh` - Use this specific shell injection scheme.
/// * `bash`, `fish`, `zsh` - Use this specific shell injection scheme.
///
/// The default value is `detect`.
@"shell-integration": ShellIntegration = .detect,
@ -3402,6 +3402,7 @@ pub const CopyOnSelect = enum {
pub const ShellIntegration = enum {
none,
detect,
bash,
fish,
zsh,
};

View File

@ -13,6 +13,66 @@
if [[ "$-" != *i* ]] ; then builtin return; fi
if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi
# When automatic shell integration is active, we need to manually
# load the normal bash startup files based on the injected state.
if [ -n "$GHOSTTY_BASH_INJECT" ]; then
builtin declare ghostty_bash_inject="$GHOSTTY_BASH_INJECT"
builtin unset GHOSTTY_BASH_INJECT ENV
# At this point, we're in POSIX mode and rely on the injected
# flags to guide is through the rest of the startup sequence.
# POSIX mode was requested by the user so there's nothing
# more to do that optionally source their original $ENV.
# No other startup files are read, per the standard.
if [[ "$ghostty_bash_inject" == *"--posix"* ]]; then
if [ -n "$GHOSTTY_BASH_ENV" ]; then
builtin source "$GHOSTTY_BASH_ENV"
builtin export ENV="$GHOSTTY_BASH_ENV"
fi
else
# Restore bash's default 'posix' behavior. Also reset 'inherit_errexit',
# which doesn't happen as part of the 'posix' reset.
builtin set +o posix
builtin shopt -u inherit_errexit 2>/dev/null
# Unexport HISTFILE if it was set by the shell integration code.
if [[ -n "$GHOSTTY_BASH_UNEXPORT_HISTFILE" ]]; then
builtin export -n HISTFILE
builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE
fi
# Manually source the startup files, respecting the injected flags like
# --norc and --noprofile that we parsed with the shell integration code.
#
# See also: run_startup_files() in shell.c in the Bash source code
if builtin shopt -q login_shell; then
if [[ $ghostty_bash_inject != *"--noprofile"* ]]; then
[ -r /etc/profile ] && builtin source "/etc/profile"
for rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
[ -r "$rcfile" ] && { builtin source "$rcfile"; break; }
done
fi
else
if [[ $ghostty_bash_inject != *"--norc"* ]]; then
# The location of the system bashrc is determined at bash build
# time via -DSYS_BASHRC and can therefore vary across distros:
# Arch, Debian, Ubuntu use /etc/bash.bashrc
# Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC
# Void Linux uses /etc/bash/bashrc
for rcfile in /etc/bash.bashrc /etc/bash/bashrc ; do
[ -r "$rcfile" ] && { builtin source "$rcfile"; break; }
done
if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi
[ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE"
fi
fi
fi
builtin unset GHOSTTY_BASH_ENV GHOSTTY_BASH_RCFILE
builtin unset ghostty_bash_inject rcfile
fi
# Import bash-preexec, safe to do multiple times
builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh"

View File

@ -908,7 +908,7 @@ const Subprocess = struct {
const alloc = arena.allocator();
// Determine the shell command we're executing
const shell_command = opts.full_config.command orelse switch (builtin.os.tag) {
var shell_command = opts.full_config.command orelse switch (builtin.os.tag) {
.windows => "cmd.exe",
else => "sh",
};
@ -1019,6 +1019,41 @@ const Subprocess = struct {
env.remove("GHOSTTY_MAC_APP");
}
// Setup our shell integration, if we can.
const integrated_shell: ?shell_integration.ShellIntegration = shell: {
const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") {
.none => break :shell null,
.detect => null,
.bash => .bash,
.fish => .fish,
.zsh => .zsh,
};
const dir = opts.resources_dir orelse break :shell null;
break :shell try shell_integration.setup(
gpa,
dir,
shell_command,
&env,
force,
opts.full_config.@"shell-integration-features",
);
};
defer if (integrated_shell) |shell| shell.deinit(gpa);
if (integrated_shell) |shell| {
log.info(
"shell integration automatically injected shell={}",
.{shell.shell},
);
if (shell.command) |command| {
shell_command = command;
}
} else if (opts.full_config.@"shell-integration" != .none) {
log.warn("shell could not be detected, no automatic shell integration will be injected", .{});
}
// Build our args list
const args = args: {
const cap = 9; // the most we'll ever use
@ -1157,35 +1192,6 @@ const Subprocess = struct {
else
null;
// Setup our shell integration, if we can.
const shell_integrated: ?shell_integration.Shell = shell: {
const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") {
.none => break :shell null,
.detect => null,
.fish => .fish,
.zsh => .zsh,
};
const dir = opts.resources_dir orelse break :shell null;
break :shell try shell_integration.setup(
gpa,
dir,
shell_command,
&env,
force,
opts.full_config.@"shell-integration-features",
);
};
if (shell_integrated) |shell| {
log.info(
"shell integration automatically injected shell={}",
.{shell},
);
} else if (opts.full_config.@"shell-integration" != .none) {
log.warn("shell could not be detected, no automatic shell integration will be injected", .{});
}
// Our screen size should be our padded size
const padded_size = opts.screen_size.subPadding(opts.padding);

View File

@ -7,19 +7,34 @@ const log = std.log.scoped(.shell_integration);
/// Shell types we support
pub const Shell = enum {
bash,
fish,
zsh,
};
pub const ShellIntegration = struct {
/// The successfully-integrated shell.
shell: Shell,
/// A revised shell command. This value will be allocated
/// with the setup() function's allocator and becomes the
/// caller's responsibility to free it.
command: ?[]const u8 = null,
pub fn deinit(self: ShellIntegration, alloc: Allocator) void {
if (self.command) |command| {
alloc.free(command);
}
}
};
/// Setup the command execution environment for automatic
/// integrated shell integration. This returns true if shell
/// integration was successful. False could mean many things:
/// the shell type wasn't detected, etc.
/// integrated shell integration and return a ShellIntegration
/// struct describing the integration. If integration fails
/// (shell type couldn't be detected, etc.), this will return null.
///
/// The allocator is only used for temporary values, so it should
/// be given a general purpose allocator. No allocated memory remains
/// after this function returns except anything allocated by the
/// EnvMap.
/// The allocator is used for temporary values and to allocate values
/// in the ShellIntegration result.
pub fn setup(
alloc: Allocator,
resource_dir: []const u8,
@ -27,8 +42,9 @@ pub fn setup(
env: *EnvMap,
force_shell: ?Shell,
features: config.ShellIntegrationFeatures,
) !?Shell {
) !?ShellIntegration {
const exe = if (force_shell) |shell| switch (shell) {
.bash => "bash",
.fish => "fish",
.zsh => "zsh",
} else exe: {
@ -38,7 +54,14 @@ pub fn setup(
break :exe std.fs.path.basename(command[0..idx]);
};
var new_command: ?[]const u8 = null;
const shell: Shell = shell: {
if (std.mem.eql(u8, "bash", exe)) {
new_command = try setupBash(alloc, command, resource_dir, env);
if (new_command == null) return null;
break :shell .bash;
}
if (std.mem.eql(u8, "fish", exe)) {
try setupFish(alloc, resource_dir, env);
break :shell .fish;
@ -57,7 +80,297 @@ pub fn setup(
if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1");
if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1");
return shell;
return .{
.shell = shell,
.command = new_command,
};
}
test "force shell" {
const testing = std.testing;
const alloc = testing.allocator;
var env = EnvMap.init(alloc);
defer env.deinit();
inline for (@typeInfo(Shell).Enum.fields) |field| {
const shell = @field(Shell, field.name);
const result = try setup(alloc, ".", "sh", &env, shell, .{});
try testing.expect(result != null);
if (result) |r| {
try testing.expectEqual(shell, r.shell);
r.deinit(alloc);
}
}
}
/// Setup the bash automatic shell integration. This works by
/// starting bash in POSIX mode and using the ENV environment
/// variable to load our bash integration script. This prevents
/// bash from loading its normal startup files, which becomes
/// our script's responsibility (along with disabling POSIX
/// mode).
///
/// This returns a new (allocated) shell command string that
/// enables the integration or null if integration failed.
fn setupBash(
alloc: Allocator,
command: []const u8,
resource_dir: []const u8,
env: *EnvMap,
) !?[]const u8 {
// Accumulates the arguments that will form the final shell command line.
// We can build this list on the stack because we're just temporarily
// referencing other slices, but we can fall back to heap in extreme cases.
var args_alloc = std.heap.stackFallback(1024, alloc);
var args = try std.ArrayList([]const u8).initCapacity(args_alloc.get(), 2);
defer args.deinit();
// Iterator that yields each argument in the original command line.
// This will allocate once proportionate to the command line length.
var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, command);
defer iter.deinit();
// Start accumulating arguments with the executable and `--posix` mode flag.
if (iter.next()) |exe| {
try args.append(exe);
} else return null;
try args.append("--posix");
// Stores the list of intercepted command line flags that will be passed
// to our shell integration script: --posix --norc --noprofile
// We always include at least "1" so the script can differentiate between
// being manually sourced or automatically injected (from here).
var inject = try std.BoundedArray(u8, 32).init(0);
try inject.appendSlice("1");
var posix = false;
// Some additional cases we don't yet cover:
//
// - If the `c` shell option is set, interactive mode is disabled, so skip
// loading our shell integration.
// - If additional file arguments are provided (after a `-` or `--` flag),
// and the `i` shell option isn't being explicitly set, we can assume a
// non-interactive shell session and skip loading our shell integration.
while (iter.next()) |arg| {
if (std.mem.eql(u8, arg, "--posix")) {
try inject.appendSlice(" --posix");
posix = true;
} else if (std.mem.eql(u8, arg, "--norc")) {
try inject.appendSlice(" --norc");
} else if (std.mem.eql(u8, arg, "--noprofile")) {
try inject.appendSlice(" --noprofile");
} else if (std.mem.eql(u8, arg, "--rcfile") or std.mem.eql(u8, arg, "--init-file")) {
if (iter.next()) |rcfile| {
try env.put("GHOSTTY_BASH_RCFILE", rcfile);
}
} else {
try args.append(arg);
}
}
try env.put("GHOSTTY_BASH_INJECT", inject.slice());
// In POSIX mode, HISTFILE defaults to ~/.sh_history.
if (!posix and env.get("HISTFILE") == null) {
try env.put("HISTFILE", "~/.bash_history");
try env.put("GHOSTTY_BASH_UNEXPORT_HISTFILE", "1");
}
// Preserve the existing ENV value in POSIX mode.
if (env.get("ENV")) |old| {
if (posix) {
try env.put("GHOSTTY_BASH_ENV", old);
}
}
// Set our new ENV to point to our integration script.
var path_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
const integ_dir = try std.fmt.bufPrint(
&path_buf,
"{s}/shell-integration/bash/ghostty.bash",
.{resource_dir},
);
try env.put("ENV", integ_dir);
// Join the acculumated arguments to form the final command string.
return try std.mem.join(alloc, " ", args.items);
}
test "bash" {
const testing = std.testing;
const alloc = testing.allocator;
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, "bash", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("bash --posix", command.?);
try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?);
try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?);
}
test "bash: inject flags" {
const testing = std.testing;
const alloc = testing.allocator;
// bash --posix
{
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, "bash --posix", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("bash --posix", command.?);
try testing.expectEqualStrings("1 --posix", env.get("GHOSTTY_BASH_INJECT").?);
}
// bash --norc
{
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, "bash --norc", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("bash --posix", command.?);
try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?);
}
// bash --noprofile
{
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, "bash --noprofile", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("bash --posix", command.?);
try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?);
}
}
test "bash: rcfile" {
const testing = std.testing;
const alloc = testing.allocator;
var env = EnvMap.init(alloc);
defer env.deinit();
// bash --rcfile
{
const command = try setupBash(alloc, "bash --rcfile profile.sh", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("bash --posix", command.?);
try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
}
// bash --init-file
{
const command = try setupBash(alloc, "bash --init-file profile.sh", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("bash --posix", command.?);
try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
}
}
test "bash: HISTFILE" {
const testing = std.testing;
const alloc = testing.allocator;
// HISTFILE unset
{
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, "bash", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("~/.bash_history", env.get("HISTFILE").?);
try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE").?);
}
// HISTFILE set
{
var env = EnvMap.init(alloc);
defer env.deinit();
try env.put("HISTFILE", "my_history");
const command = try setupBash(alloc, "bash", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("my_history", env.get("HISTFILE").?);
try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null);
}
// HISTFILE unset (POSIX mode)
{
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, "bash --posix", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expect(env.get("HISTFILE") == null);
try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null);
}
// HISTFILE set (POSIX mode)
{
var env = EnvMap.init(alloc);
defer env.deinit();
try env.put("HISTFILE", "my_history");
const command = try setupBash(alloc, "bash --posix", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expectEqualStrings("my_history", env.get("HISTFILE").?);
try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null);
}
}
test "bash: preserve ENV" {
const testing = std.testing;
const alloc = testing.allocator;
var env = EnvMap.init(alloc);
defer env.deinit();
const original_env = "original-env.bash";
// POSIX mode
{
try env.put("ENV", original_env);
const command = try setupBash(alloc, "bash --posix", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expect(std.mem.indexOf(u8, command.?, "--posix") != null);
try testing.expect(std.mem.indexOf(u8, env.get("GHOSTTY_BASH_INJECT").?, "posix") != null);
try testing.expectEqualStrings(original_env, env.get("GHOSTTY_BASH_ENV").?);
try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?);
}
env.remove("GHOSTTY_BASH_ENV");
// Not POSIX mode
{
try env.put("ENV", original_env);
const command = try setupBash(alloc, "bash", ".", &env);
defer if (command) |c| alloc.free(c);
try testing.expect(std.mem.indexOf(u8, command.?, "--posix") != null);
try testing.expect(std.mem.indexOf(u8, env.get("GHOSTTY_BASH_INJECT").?, "posix") == null);
try testing.expect(env.get("GHOSTTY_BASH_ENV") == null);
try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?);
}
}
/// Setup the fish automatic shell integration. This works by
@ -128,16 +441,3 @@ fn setupZsh(
);
try env.put("ZDOTDIR", integ_dir);
}
test "force shell" {
const testing = std.testing;
const alloc = testing.allocator;
var env = EnvMap.init(alloc);
defer env.deinit();
inline for (@typeInfo(Shell).Enum.fields) |field| {
const shell = @field(Shell, field.name);
try testing.expectEqual(shell, setup(alloc, ".", "sh", &env, shell, .{}));
}
}