diff --git a/README.md b/README.md index e9456d5dd..095d4d03d 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,10 @@ The currently supported shell integration features in Ghostty: #### Shell Integration Installation and Verification -Ghostty will automatically inject the shell integration code for `zsh` and -`fish`. `bash` does not support automatic injection but you can manually -`source` the `ghostty.bash` file in `src/shell-integration`. Other shells are -not supported. **If you want to disable this feature,** set +Ghostty will automatically inject the shell integration code for `bash`, `zsh` +and `fish`. Other shells do not have shell integration code written but will +function fine within Ghostty with the above mentioned shell integration features +inoperative. **If you want to disable automatic shell integration,** set `shell-integration = none` in your configuration file. **For the automatic shell integration to work,** Ghostty must either be run 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..9063ca67f 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -907,12 +907,6 @@ const Subprocess = struct { errdefer arena.deinit(); const alloc = arena.allocator(); - // Determine the shell command we're executing - const shell_command = opts.full_config.command orelse switch (builtin.os.tag) { - .windows => "cmd.exe", - else => "sh", - }; - // Set our env vars. For Flatpak builds running in Flatpak we don't // inherit our environment because the login shell on the host side // will get it. @@ -1019,6 +1013,47 @@ const Subprocess = struct { env.remove("GHOSTTY_MAC_APP"); } + // Setup our shell integration, if we can. + const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { + const default_shell_command = opts.full_config.command orelse switch (builtin.os.tag) { + .windows => "cmd.exe", + else => "sh", + }; + + const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { + .none => break :shell .{ null, default_shell_command }, + .detect => null, + .bash => .bash, + .fish => .fish, + .zsh => .zsh, + }; + + const dir = opts.resources_dir orelse break :shell .{ + null, + default_shell_command, + }; + + const integration = try shell_integration.setup( + alloc, + dir, + default_shell_command, + &env, + force, + opts.full_config.@"shell-integration-features", + ) orelse break :shell .{ null, default_shell_command }; + + break :shell .{ integration.shell, integration.command }; + }; + + if (integrated_shell) |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", .{}); + } + // 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..413d9e186 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const EnvMap = std.process.EnvMap; const config = @import("../config.zig"); @@ -7,28 +8,42 @@ const log = std.log.scoped(.shell_integration); /// Shell types we support pub const Shell = enum { + bash, fish, zsh, }; +/// The result of setting up a shell integration. +pub const ShellIntegration = struct { + /// The successfully-integrated shell. + shell: Shell, + + /// The command to use to start the shell with the integration. + /// In most cases this is identical to the command given but for + /// bash in particular it may be different. + /// + /// The memory is allocated in the arena given to setup. + command: []const u8, +}; + /// 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. It is expected to be an arena to +/// simplify cleanup. pub fn setup( - alloc: Allocator, + alloc_arena: Allocator, resource_dir: []const u8, command: []const u8, 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,15 +53,34 @@ pub fn setup( break :exe std.fs.path.basename(command[0..idx]); }; - const shell: Shell = shell: { + const result: ShellIntegration = shell: { + if (std.mem.eql(u8, "bash", exe)) { + const new_command = try setupBash( + alloc_arena, + command, + resource_dir, + env, + ) orelse return null; + break :shell .{ + .shell = .bash, + .command = new_command, + }; + } + if (std.mem.eql(u8, "fish", exe)) { - try setupFish(alloc, resource_dir, env); - break :shell .fish; + try setupFish(alloc_arena, resource_dir, env); + break :shell .{ + .shell = .fish, + .command = command, + }; } if (std.mem.eql(u8, "zsh", exe)) { try setupZsh(resource_dir, env); - break :shell .zsh; + break :shell .{ + .shell = .zsh, + .command = command, + }; } return null; @@ -57,7 +91,292 @@ 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 result; +} + +test "force shell" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.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.expectEqual(shell, result.?.shell); + } +} + +/// 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 @@ -65,7 +384,7 @@ pub fn setup( /// Fish will automatically load configuration in XDG_DATA_DIRS /// "fish/vendor_conf.d/*.fish". fn setupFish( - alloc_gpa: Allocator, + alloc_arena: Allocator, resource_dir: []const u8, env: *EnvMap, ) !void { @@ -91,7 +410,7 @@ fn setupFish( // 4K is a reasonable size for this for most cases. However, env // vars can be significantly larger so if we have to we fall // back to a heap allocated value. - var stack_alloc = std.heap.stackFallback(4096, alloc_gpa); + var stack_alloc = std.heap.stackFallback(4096, alloc_arena); const alloc = stack_alloc.get(); const prepended = try std.fmt.allocPrint( alloc, @@ -128,16 +447,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, .{})); - } -}