diff options
Diffstat (limited to 'vendor/libghostty-vt/src/termio/shell_integration.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/termio/shell_integration.zig | 1032 |
1 files changed, 1032 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/termio/shell_integration.zig b/vendor/libghostty-vt/src/termio/shell_integration.zig new file mode 100644 index 00000000..2dd09ee2 --- /dev/null +++ b/vendor/libghostty-vt/src/termio/shell_integration.zig @@ -0,0 +1,1032 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const EnvMap = std.process.EnvMap; +const config = @import("../config.zig"); +const homedir = @import("../os/homedir.zig"); +const internal_os = @import("../os/main.zig"); + +const log = std.log.scoped(.shell_integration); + +/// Shell types we support +pub const Shell = enum { + bash, + elvish, + fish, + nushell, + 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: config.Command, +}; + +/// Set up the command execution environment for automatic +/// 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 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_arena: Allocator, + resource_dir: []const u8, + command: config.Command, + env: *EnvMap, + force_shell: ?Shell, +) !?ShellIntegration { + const shell: Shell = force_shell orelse + try detectShell(alloc_arena, command) orelse + return null; + + const new_command: config.Command = switch (shell) { + .bash => try setupBash( + alloc_arena, + command, + resource_dir, + env, + ), + + .nushell => try setupNushell( + alloc_arena, + command, + resource_dir, + env, + ), + + .zsh => try setupZsh( + alloc_arena, + command, + resource_dir, + env, + ), + + .elvish, .fish => xdg: { + if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null; + break :xdg try command.clone(alloc_arena); + }, + } orelse return null; + + return .{ + .shell = shell, + .command = new_command, + }; +} + +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); + + var res: TmpResourcesDir = try .init(alloc, shell); + defer res.deinit(); + + const result = try setup( + alloc, + res.path, + .{ .shell = "sh" }, + &env, + shell, + ); + try testing.expectEqual(shell, result.?.shell); + } +} + +test "shell integration failure" { + 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(); + + const result = try setup( + alloc, + "/nonexistent", + .{ .shell = "sh" }, + &env, + null, + ); + + try testing.expect(result == null); + try testing.expectEqual(0, env.count()); +} + +fn detectShell(alloc: Allocator, command: config.Command) !?Shell { + var arg_iter = try command.argIterator(alloc); + defer arg_iter.deinit(); + + const arg0 = arg_iter.next() orelse return null; + const exe = std.fs.path.basename(arg0); + + if (std.mem.eql(u8, "bash", exe)) { + // Apple distributes their own patched version of Bash 3.2 + // on macOS that disables the ENV-based POSIX startup path. + // This means we're unable to perform our automatic shell + // integration sequence in this specific environment. + // + // If we're running "/bin/bash" on Darwin, we can assume + // we're using Apple's Bash because /bin is non-writable + // on modern macOS due to System Integrity Protection. + if (comptime builtin.target.os.tag.isDarwin()) { + if (std.mem.eql(u8, "/bin/bash", arg0)) { + return null; + } + } + return .bash; + } + + if (std.mem.eql(u8, "elvish", exe)) return .elvish; + if (std.mem.eql(u8, "fish", exe)) return .fish; + if (std.mem.eql(u8, "nu", exe)) return .nushell; + if (std.mem.eql(u8, "zsh", exe)) return .zsh; + + return null; +} + +test detectShell { + const testing = std.testing; + const alloc = testing.allocator; + + try testing.expect(try detectShell(alloc, .{ .shell = "sh" }) == null); + try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash" })); + try testing.expectEqual(.elvish, try detectShell(alloc, .{ .shell = "elvish" })); + try testing.expectEqual(.fish, try detectShell(alloc, .{ .shell = "fish" })); + try testing.expectEqual(.nushell, try detectShell(alloc, .{ .shell = "nu" })); + try testing.expectEqual(.zsh, try detectShell(alloc, .{ .shell = "zsh" })); + + if (comptime builtin.target.os.tag.isDarwin()) { + try testing.expect(try detectShell(alloc, .{ .shell = "/bin/bash" }) == null); + } + + try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash -c 'command'" })); + try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "\"/a b/bash\"" })); +} + +/// Set up the shell integration features environment variable. +pub fn setupFeatures( + env: *EnvMap, + features: config.ShellIntegrationFeatures, + cursor_blink: bool, +) !void { + const fields = @typeInfo(@TypeOf(features)).@"struct".fields; + const capacity: usize = capacity: { + comptime var n: usize = fields.len - 1; // commas + inline for (fields) |field| n += field.name.len; + n += ":steady".len; // cursor value + break :capacity n; + }; + + var buf: [capacity]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + // Sort the fields so that the output is deterministic. This is + // done at comptime so it has no runtime cost + const fields_sorted: [fields.len][]const u8 = comptime fields: { + var fields_sorted: [fields.len][]const u8 = undefined; + for (fields, 0..) |field, i| fields_sorted[i] = field.name; + std.mem.sortUnstable( + []const u8, + &fields_sorted, + {}, + (struct { + fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.ascii.orderIgnoreCase(lhs, rhs) == .lt; + } + }).lessThan, + ); + break :fields fields_sorted; + }; + + inline for (fields_sorted) |name| { + if (@field(features, name)) { + if (writer.end > 0) try writer.writeByte(','); + try writer.writeAll(name); + + if (std.mem.eql(u8, name, "cursor")) { + try writer.writeAll(if (cursor_blink) ":blink" else ":steady"); + } + } + } + + if (writer.end > 0) { + try env.put("GHOSTTY_SHELL_FEATURES", buf[0..writer.end]); + } +} + +test "setup features" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Test: all features enabled + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true, .path = true }, true); + try testing.expectEqualStrings("cursor:blink,path,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?); + } + + // Test: all features disabled + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures), true); + try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null); + } + + // Test: mixed features + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false, .path = false }, true); + try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?); + } + + // Test: blinking cursor + { + var env = EnvMap.init(alloc); + defer env.deinit(); + try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, true); + try testing.expectEqualStrings("cursor:blink", env.get("GHOSTTY_SHELL_FEATURES").?); + } + + // Test: steady cursor + { + var env = EnvMap.init(alloc); + defer env.deinit(); + try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, false); + try testing.expectEqualStrings("cursor:steady", env.get("GHOSTTY_SHELL_FEATURES").?); + } +} + +/// 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: config.Command, + resource_dir: []const u8, + env: *EnvMap, +) !?config.Command { + var stack_fallback = std.heap.stackFallback(4096, alloc); + var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); + defer cmd.deinit(); + + // Iterator that yields each argument in the original command line. + // This will allocate once proportionate to the command line length. + var iter = try command.argIterator(alloc); + defer iter.deinit(); + + // Start accumulating arguments with the executable and initial flags. + if (iter.next()) |exe| { + try cmd.appendArg(exe); + } else return null; + try cmd.appendArg("--posix"); + + // Stores the list of intercepted command line flags that will be passed + // to our shell integration script: --norc --noprofile + // We always include at least "1" so the script can differentiate between + // being manually sourced or automatically injected (from here). + var buf: [32]u8 = undefined; + var inject: std.Io.Writer = .fixed(&buf); + try inject.writeAll("1"); + + // Walk through the rest of the given arguments. If we see an option that + // would require complex or unsupported integration behavior, we bail out + // and skip loading our shell integration. Users can still manually source + // the shell integration script. + // + // Unsupported options: + // -c -c is always non-interactive + // --posix POSIX mode (a la /bin/sh) + var rcfile: ?[]const u8 = null; + while (iter.next()) |arg| { + if (std.mem.eql(u8, arg, "--posix")) { + return null; + } else if (std.mem.eql(u8, arg, "--norc")) { + try inject.writeAll(" --norc"); + } else if (std.mem.eql(u8, arg, "--noprofile")) { + try inject.writeAll(" --noprofile"); + } else if (std.mem.eql(u8, arg, "--rcfile") or std.mem.eql(u8, arg, "--init-file")) { + rcfile = iter.next(); + } else if (arg.len > 1 and arg[0] == '-' and arg[1] != '-') { + // '-c command' is always non-interactive + if (std.mem.indexOfScalar(u8, arg, 'c') != null) { + return null; + } + try cmd.appendArg(arg); + } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { + // All remaining arguments should be passed directly to the shell + // command. We shouldn't perform any further option processing. + try cmd.appendArg(arg); + while (iter.next()) |remaining_arg| { + try cmd.appendArg(remaining_arg); + } + break; + } else { + try cmd.appendArg(arg); + } + } + + // Preserve an existing ENV value. We're about to overwrite it. + if (env.get("ENV")) |v| { + try env.put("GHOSTTY_BASH_ENV", v); + } + + // Set our new ENV to point to our integration script. + var script_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const script_path = try std.fmt.bufPrint( + &script_path_buf, + "{s}/shell-integration/bash/ghostty.bash", + .{resource_dir}, + ); + if (std.fs.openFileAbsolute(script_path, .{})) |file| { + file.close(); + try env.put("ENV", script_path); + } else |err| { + log.warn("unable to open {s}: {}", .{ script_path, err }); + env.remove("GHOSTTY_BASH_ENV"); + return null; + } + + try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]); + if (rcfile) |v| { + try env.put("GHOSTTY_BASH_RCFILE", v); + } + + // In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're + // staying in POSIX mode (--posix), change it back to ~/.bash_history. + if (env.get("HISTFILE") == null) { + var home_buf: [1024]u8 = undefined; + if (try homedir.home(&home_buf)) |home| { + var histfile_buf: [std.fs.max_path_bytes]u8 = undefined; + const histfile = try std.fmt.bufPrint( + &histfile_buf, + "{s}/.bash_history", + .{home}, + ); + try env.put("HISTFILE", histfile); + try env.put("GHOSTTY_BASH_UNEXPORT_HISTFILE", "1"); + } + } + + // Return a copy of our modified command line to use as the shell command. + return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; +} + +test "bash" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + const command = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); + try testing.expectEqualStrings("bash --posix", command.?.shell); + try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}), + env.get("ENV").?, + ); +} + +test "bash: unsupported options" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + + const cmdlines = [_][:0]const u8{ + "bash --posix", + "bash --rcfile script.sh --posix", + "bash --init-file script.sh --posix", + "bash -c script.sh", + "bash -ic script.sh", + }; + + for (cmdlines) |cmdline| { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, res.path, &env) == null); + try testing.expectEqual(0, env.count()); + } +} + +test "bash: inject flags" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + + // bash --norc + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + const command = try setupBash(alloc, .{ .shell = "bash --norc" }, res.path, &env); + try testing.expectEqualStrings("bash --posix", command.?.shell); + 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, .{ .shell = "bash --noprofile" }, res.path, &env); + try testing.expectEqualStrings("bash --posix", command.?.shell); + try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?); + } +} + +test "bash: rcfile" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + // bash --rcfile + { + const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, res.path, &env); + try testing.expectEqualStrings("bash --posix", command.?.shell); + try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); + } + + // bash --init-file + { + const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, res.path, &env); + try testing.expectEqualStrings("bash --posix", command.?.shell); + try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); + } +} + +test "bash: HISTFILE" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + + // HISTFILE unset + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); + try testing.expect(std.mem.endsWith(u8, env.get("HISTFILE").?, ".bash_history")); + 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"); + + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); + try testing.expectEqualStrings("my_history", env.get("HISTFILE").?); + try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); + } +} + +test "bash: ENV" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try env.put("ENV", "env.sh"); + + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); + try testing.expectEqualStrings("env.sh", env.get("GHOSTTY_BASH_ENV").?); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}), + env.get("ENV").?, + ); +} + +test "bash: additional arguments" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + // "-" argument separator + { + const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, res.path, &env); + try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell); + } + + // "--" argument separator + { + const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, res.path, &env); + try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell); + } +} + +test "bash: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupBash(alloc, .{ .shell = "bash" }, resources_dir, &env) == null); + try testing.expectEqual(0, env.count()); +} + +/// Setup automatic shell integration for shells that include +/// their modules from paths in `XDG_DATA_DIRS` env variable. +/// +/// The shell-integration path is prepended to `XDG_DATA_DIRS`. +/// It is also saved in the `GHOSTTY_SHELL_INTEGRATION_XDG_DIR` variable +/// so that the shell can refer to it and safely remove this directory +/// from `XDG_DATA_DIRS` when integration is complete. +fn setupXdgDataDirs( + alloc: Allocator, + resource_dir: []const u8, + env: *EnvMap, +) !bool { + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + + // Get our path to the shell integration directory. + const integ_path = try std.fmt.bufPrint( + &path_buf, + "{s}/shell-integration", + .{resource_dir}, + ); + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { + log.warn("unable to open {s}: {}", .{ integ_path, err }); + return false; + }; + integ_dir.close(); + + // Set an env var so we can remove this from XDG_DATA_DIRS later. + // This happens in the shell integration config itself. We do this + // so that our modifications don't interfere with other commands. + try env.put("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", integ_path); + + // We attempt to avoid allocating by using the stack up to 4K. + // Max stack size is considerably larger on mac + // 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_state = std.heap.stackFallback(4096, alloc); + const stack_alloc = stack_alloc_state.get(); + + // If no XDG_DATA_DIRS set use the default value as specified. + // This ensures that the default directories aren't lost by setting + // our desired integration dir directly. See #2711. + // <https://specifications.freedesktop.org/basedir-spec/0.6/#variables> + const xdg_data_dirs_key = "XDG_DATA_DIRS"; + try env.put( + xdg_data_dirs_key, + try internal_os.prependEnv( + stack_alloc, + env.get(xdg_data_dirs_key) orelse "/usr/local/share:/usr/share", + integ_path, + ), + ); + + return true; +} + +test "xdg: empty XDG_DATA_DIRS" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .fish); + defer res.deinit(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?, + ); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/usr/local/share:/usr/share", .{res.path}), + env.get("XDG_DATA_DIRS").?, + ); +} + +test "xdg: existing XDG_DATA_DIRS" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .fish); + defer res.deinit(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try env.put("XDG_DATA_DIRS", "/opt/share"); + + try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?, + ); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/opt/share", .{res.path}), + env.get("XDG_DATA_DIRS").?, + ); +} + +test "xdg: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env)); + try testing.expectEqual(0, env.count()); +} + +/// Set up automatic Nushell shell integration. This works by adding our +/// shell resource directory to the `XDG_DATA_DIRS` environment variable, +/// which Nushell will use to load `nushell/vendor/autoload/ghostty.nu`. +/// +/// We then add `--execute 'use ghostty ...'` to the nu command line to +/// automatically enable our shelll features. +fn setupNushell( + alloc: Allocator, + command: config.Command, + resource_dir: []const u8, + env: *EnvMap, +) !?config.Command { + // Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This + // makes our 'ghostty' module automatically available, even if any + // of the later checks abort the rest of our automatic integration. + if (!try setupXdgDataDirs(alloc, resource_dir, env)) return null; + + var stack_fallback = std.heap.stackFallback(4096, alloc); + var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); + defer cmd.deinit(); + + // Iterator that yields each argument in the original command line. + // This will allocate once proportionate to the command line length. + var iter = try command.argIterator(alloc); + defer iter.deinit(); + + // Start accumulating arguments with the executable and initial flags. + if (iter.next()) |exe| { + try cmd.appendArg(exe); + } else return null; + + // Tell nu to immediately "use" all of the exported functions in our + // 'ghostty' module. + // + // We can consider making this more specific based on the set of + // enabled shell features (e.g. `use ghostty sudo`). At the moment, + // shell features are all runtime-guarded in the nushell script. + try cmd.appendArg("--execute 'use ghostty *'"); + + // Walk through the rest of the given arguments. If we see an option that + // would require complex or unsupported integration behavior, we bail out + // and skip loading our shell integration. Users can still manually source + // the shell integration module. + // + // Unsupported options: + // -c / --command -c is always non-interactive + // --lsp --lsp starts the language server + while (iter.next()) |arg| { + if (std.mem.eql(u8, arg, "--command") or std.mem.eql(u8, arg, "--lsp")) { + return null; + } else if (arg.len > 1 and arg[0] == '-' and arg[1] != '-') { + if (std.mem.indexOfScalar(u8, arg, 'c') != null) { + return null; + } + try cmd.appendArg(arg); + } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { + // All remaining arguments should be passed directly to the shell + // command. We shouldn't perform any further option processing. + try cmd.appendArg(arg); + while (iter.next()) |remaining_arg| { + try cmd.appendArg(remaining_arg); + } + break; + } else { + try cmd.appendArg(arg); + } + } + + // Return a copy of our modified command line to use as the shell command. + return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; +} + +test "nushell" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .nushell); + defer res.deinit(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + const command = try setupNushell(alloc, .{ .shell = "nu" }, res.path, &env); + try testing.expectEqualStrings("nu --execute 'use ghostty *'", command.?.shell); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?, + ); + try testing.expectStringStartsWith( + env.get("XDG_DATA_DIRS").?, + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + ); +} + +test "nushell: unsupported options" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .nushell); + defer res.deinit(); + + const cmdlines = [_][:0]const u8{ + "nu --command exit", + "nu --lsp", + "nu -c script.sh", + "nu -ic script.sh", + }; + + for (cmdlines) |cmdline| { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupNushell(alloc, .{ .shell = cmdline }, res.path, &env) == null); + try testing.expect(env.get("XDG_DATA_DIRS") != null); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR") != null); + } +} + +test "nushell: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupNushell(alloc, .{ .shell = "nu" }, resources_dir, &env) == null); + try testing.expectEqual(0, env.count()); +} + +/// Setup the zsh automatic shell integration. This works by setting +/// ZDOTDIR to our resources dir so that zsh will load our config. This +/// config then loads the true user config. +fn setupZsh( + alloc: Allocator, + command: config.Command, + resource_dir: []const u8, + env: *EnvMap, +) !?config.Command { + // Preserve an existing ZDOTDIR value. We're about to overwrite it. + if (env.get("ZDOTDIR")) |old| { + try env.put("GHOSTTY_ZSH_ZDOTDIR", old); + } + + // Set our new ZDOTDIR to point to our shell resource directory. + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const integ_path = try std.fmt.bufPrint( + &path_buf, + "{s}/shell-integration/zsh", + .{resource_dir}, + ); + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { + log.warn("unable to open {s}: {}", .{ integ_path, err }); + return null; + }; + integ_dir.close(); + try env.put("ZDOTDIR", integ_path); + + return try command.clone(alloc); +} + +test "zsh" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(testing.allocator, .zsh); + defer res.deinit(); + + var env = EnvMap.init(testing.allocator); + defer env.deinit(); + + const command = try setupZsh(alloc, .{ .shell = "zsh" }, res.path, &env); + try testing.expectEqualStrings("zsh", command.?.shell); + try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?); + try testing.expect(env.get("GHOSTTY_ZSH_ZDOTDIR") == null); +} + +test "zsh: ZDOTDIR" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(testing.allocator, .zsh); + defer res.deinit(); + + var env = EnvMap.init(testing.allocator); + defer env.deinit(); + + try env.put("ZDOTDIR", "$HOME/.config/zsh"); + + const command = try setupZsh(alloc, .{ .shell = "zsh" }, res.path, &env); + try testing.expectEqualStrings("zsh", command.?.shell); + try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?); + try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?); +} + +test "zsh: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupZsh(alloc, .{ .shell = "zsh" }, resources_dir, &env) == null); + try testing.expectEqual(0, env.count()); +} + +/// Test helper that creates a temporary resources directory with shell integration paths. +const TmpResourcesDir = struct { + allocator: Allocator, + tmp_dir: std.testing.TmpDir, + path: []const u8, + shell_path: []const u8, + + fn init(allocator: Allocator, shell: Shell) !TmpResourcesDir { + var tmp_dir = std.testing.tmpDir(.{}); + errdefer tmp_dir.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const relative_shell_path = try std.fmt.bufPrint( + &path_buf, + "shell-integration/{s}", + .{@tagName(shell)}, + ); + try tmp_dir.dir.makePath(relative_shell_path); + + const path = try tmp_dir.dir.realpathAlloc(allocator, "."); + errdefer allocator.free(path); + + const shell_path = try std.fmt.allocPrint( + allocator, + "{s}/{s}", + .{ path, relative_shell_path }, + ); + errdefer allocator.free(shell_path); + + switch (shell) { + .bash => try tmp_dir.dir.writeFile(.{ + .sub_path = "shell-integration/bash/ghostty.bash", + .data = "", + }), + else => {}, + } + + return .{ + .allocator = allocator, + .tmp_dir = tmp_dir, + .path = path, + .shell_path = shell_path, + }; + } + + fn deinit(self: *TmpResourcesDir) void { + self.allocator.free(self.shell_path); + self.allocator.free(self.path); + self.tmp_dir.cleanup(); + } +}; |
