Something went wrong. Try again.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033const 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 supportpub 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(); }};