diff --git a/src/config/Config.zig b/src/config/Config.zig index f9163674d..a2d6f39a8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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, }; diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 251faf11e..6fe039682 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -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" diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 9d106f79d..8ceea4256 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -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); diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index d11d08f70..f32b59195 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -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, .{})); - } -}