Skip to content

Commit 9bddca8

Browse files
authored
shell-integration: better shell detection and setup (#10044)
Command-based shell detection has been extracted to its own function (detectShell), which is nicer for testing. It now uses argIterator to determine the command's executable, rather than the previous string operations, which allows us to handle command strings containing quotes and spaces. Also, our shell-specific setup functions now use a consistent signature, which simplifies the calling code quite a bit.
2 parents 7c4ae08 + 795de79 commit 9bddca8

File tree

1 file changed

+102
-95
lines changed

1 file changed

+102
-95
lines changed

src/termio/shell_integration.zig

Lines changed: 102 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -45,95 +45,37 @@ pub fn setup(
4545
env: *EnvMap,
4646
force_shell: ?Shell,
4747
) !?ShellIntegration {
48-
const exe = if (force_shell) |shell| switch (shell) {
49-
.bash => "bash",
50-
.elvish => "elvish",
51-
.fish => "fish",
52-
.zsh => "zsh",
53-
} else switch (command) {
54-
.direct => |v| std.fs.path.basename(v[0]),
55-
.shell => |v| exe: {
56-
// Shell strings can include spaces so we want to only
57-
// look up to the space if it exists. No shell that we integrate
58-
// has spaces.
59-
const idx = std.mem.indexOfScalar(u8, v, ' ') orelse v.len;
60-
break :exe std.fs.path.basename(v[0..idx]);
61-
},
62-
};
63-
64-
const result = try setupShell(
65-
alloc_arena,
66-
resource_dir,
67-
command,
68-
env,
69-
exe,
70-
);
71-
72-
return result;
73-
}
74-
75-
fn setupShell(
76-
alloc_arena: Allocator,
77-
resource_dir: []const u8,
78-
command: config.Command,
79-
env: *EnvMap,
80-
exe: []const u8,
81-
) !?ShellIntegration {
82-
if (std.mem.eql(u8, "bash", exe)) {
83-
// Apple distributes their own patched version of Bash 3.2
84-
// on macOS that disables the ENV-based POSIX startup path.
85-
// This means we're unable to perform our automatic shell
86-
// integration sequence in this specific environment.
87-
//
88-
// If we're running "/bin/bash" on Darwin, we can assume
89-
// we're using Apple's Bash because /bin is non-writable
90-
// on modern macOS due to System Integrity Protection.
91-
if (comptime builtin.target.os.tag.isDarwin()) {
92-
if (std.mem.eql(u8, "/bin/bash", switch (command) {
93-
.direct => |v| v[0],
94-
.shell => |v| v,
95-
})) {
96-
return null;
97-
}
98-
}
48+
const shell: Shell = force_shell orelse
49+
try detectShell(alloc_arena, command) orelse
50+
return null;
9951

100-
const new_command = try setupBash(
52+
const new_command: config.Command = switch (shell) {
53+
.bash => try setupBash(
10154
alloc_arena,
10255
command,
10356
resource_dir,
10457
env,
105-
) orelse return null;
106-
return .{
107-
.shell = .bash,
108-
.command = new_command,
109-
};
110-
}
111-
112-
if (std.mem.eql(u8, "elvish", exe)) {
113-
if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null;
114-
return .{
115-
.shell = .elvish,
116-
.command = try command.clone(alloc_arena),
117-
};
118-
}
58+
),
11959

120-
if (std.mem.eql(u8, "fish", exe)) {
121-
if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null;
122-
return .{
123-
.shell = .fish,
124-
.command = try command.clone(alloc_arena),
125-
};
126-
}
60+
.elvish, .fish => try setupXdgDataDirs(
61+
alloc_arena,
62+
command,
63+
resource_dir,
64+
env,
65+
),
12766

128-
if (std.mem.eql(u8, "zsh", exe)) {
129-
if (!try setupZsh(resource_dir, env)) return null;
130-
return .{
131-
.shell = .zsh,
132-
.command = try command.clone(alloc_arena),
133-
};
134-
}
67+
.zsh => try setupZsh(
68+
alloc_arena,
69+
command,
70+
resource_dir,
71+
env,
72+
),
73+
} orelse return null;
13574

136-
return null;
75+
return .{
76+
.shell = shell,
77+
.command = new_command,
78+
};
13779
}
13880

13981
test "force shell" {
@@ -185,6 +127,55 @@ test "shell integration failure" {
185127
try testing.expectEqual(0, env.count());
186128
}
187129

130+
fn detectShell(alloc: Allocator, command: config.Command) !?Shell {
131+
var arg_iter = try command.argIterator(alloc);
132+
defer arg_iter.deinit();
133+
134+
const arg0 = arg_iter.next() orelse return null;
135+
const exe = std.fs.path.basename(arg0);
136+
137+
if (std.mem.eql(u8, "bash", exe)) {
138+
// Apple distributes their own patched version of Bash 3.2
139+
// on macOS that disables the ENV-based POSIX startup path.
140+
// This means we're unable to perform our automatic shell
141+
// integration sequence in this specific environment.
142+
//
143+
// If we're running "/bin/bash" on Darwin, we can assume
144+
// we're using Apple's Bash because /bin is non-writable
145+
// on modern macOS due to System Integrity Protection.
146+
if (comptime builtin.target.os.tag.isDarwin()) {
147+
if (std.mem.eql(u8, "/bin/bash", arg0)) {
148+
return null;
149+
}
150+
}
151+
return .bash;
152+
}
153+
154+
if (std.mem.eql(u8, "elvish", exe)) return .elvish;
155+
if (std.mem.eql(u8, "fish", exe)) return .fish;
156+
if (std.mem.eql(u8, "zsh", exe)) return .zsh;
157+
158+
return null;
159+
}
160+
161+
test detectShell {
162+
const testing = std.testing;
163+
const alloc = testing.allocator;
164+
165+
try testing.expect(try detectShell(alloc, .{ .shell = "sh" }) == null);
166+
try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash" }));
167+
try testing.expectEqual(.elvish, try detectShell(alloc, .{ .shell = "elvish" }));
168+
try testing.expectEqual(.fish, try detectShell(alloc, .{ .shell = "fish" }));
169+
try testing.expectEqual(.zsh, try detectShell(alloc, .{ .shell = "zsh" }));
170+
171+
if (comptime builtin.target.os.tag.isDarwin()) {
172+
try testing.expect(try detectShell(alloc, .{ .shell = "/bin/bash" }) == null);
173+
}
174+
175+
try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash -c 'command'" }));
176+
try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "\"/a b/bash\"" }));
177+
}
178+
188179
/// Set up the shell integration features environment variable.
189180
pub fn setupFeatures(
190181
env: *EnvMap,
@@ -603,10 +594,11 @@ test "bash: missing resources" {
603594
/// so that the shell can refer to it and safely remove this directory
604595
/// from `XDG_DATA_DIRS` when integration is complete.
605596
fn setupXdgDataDirs(
606-
alloc_arena: Allocator,
597+
alloc: Allocator,
598+
command: config.Command,
607599
resource_dir: []const u8,
608600
env: *EnvMap,
609-
) !bool {
601+
) !?config.Command {
610602
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
611603

612604
// Get our path to the shell integration directory.
@@ -617,7 +609,7 @@ fn setupXdgDataDirs(
617609
);
618610
var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
619611
log.warn("unable to open {s}: {}", .{ integ_path, err });
620-
return false;
612+
return null;
621613
};
622614
integ_dir.close();
623615

@@ -631,7 +623,7 @@ fn setupXdgDataDirs(
631623
// 4K is a reasonable size for this for most cases. However, env
632624
// vars can be significantly larger so if we have to we fall
633625
// back to a heap allocated value.
634-
var stack_alloc_state = std.heap.stackFallback(4096, alloc_arena);
626+
var stack_alloc_state = std.heap.stackFallback(4096, alloc);
635627
const stack_alloc = stack_alloc_state.get();
636628

637629
// If no XDG_DATA_DIRS set use the default value as specified.
@@ -648,7 +640,7 @@ fn setupXdgDataDirs(
648640
),
649641
);
650642

651-
return true;
643+
return try command.clone(alloc);
652644
}
653645

654646
test "xdg: empty XDG_DATA_DIRS" {
@@ -664,7 +656,8 @@ test "xdg: empty XDG_DATA_DIRS" {
664656
var env = EnvMap.init(alloc);
665657
defer env.deinit();
666658

667-
try testing.expect(try setupXdgDataDirs(alloc, res.path, &env));
659+
const command = try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, res.path, &env);
660+
try testing.expectEqualStrings("xdg", command.?.shell);
668661

669662
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
670663
try testing.expectEqualStrings(
@@ -691,7 +684,9 @@ test "xdg: existing XDG_DATA_DIRS" {
691684
defer env.deinit();
692685

693686
try env.put("XDG_DATA_DIRS", "/opt/share");
694-
try testing.expect(try setupXdgDataDirs(alloc, res.path, &env));
687+
688+
const command = try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, res.path, &env);
689+
try testing.expectEqualStrings("xdg", command.?.shell);
695690

696691
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
697692
try testing.expectEqualStrings(
@@ -719,17 +714,19 @@ test "xdg: missing resources" {
719714
var env = EnvMap.init(alloc);
720715
defer env.deinit();
721716

722-
try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env));
717+
try testing.expect(try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, resources_dir, &env) == null);
723718
try testing.expectEqual(0, env.count());
724719
}
725720

726721
/// Setup the zsh automatic shell integration. This works by setting
727722
/// ZDOTDIR to our resources dir so that zsh will load our config. This
728723
/// config then loads the true user config.
729724
fn setupZsh(
725+
alloc: Allocator,
726+
command: config.Command,
730727
resource_dir: []const u8,
731728
env: *EnvMap,
732-
) !bool {
729+
) !?config.Command {
733730
// Preserve an existing ZDOTDIR value. We're about to overwrite it.
734731
if (env.get("ZDOTDIR")) |old| {
735732
try env.put("GHOSTTY_ZSH_ZDOTDIR", old);
@@ -744,31 +741,40 @@ fn setupZsh(
744741
);
745742
var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
746743
log.warn("unable to open {s}: {}", .{ integ_path, err });
747-
return false;
744+
return null;
748745
};
749746
integ_dir.close();
750747
try env.put("ZDOTDIR", integ_path);
751748

752-
return true;
749+
return try command.clone(alloc);
753750
}
754751

755752
test "zsh" {
756753
const testing = std.testing;
757754

755+
var arena = ArenaAllocator.init(testing.allocator);
756+
defer arena.deinit();
757+
const alloc = arena.allocator();
758+
758759
var res: TmpResourcesDir = try .init(testing.allocator, .zsh);
759760
defer res.deinit();
760761

761762
var env = EnvMap.init(testing.allocator);
762763
defer env.deinit();
763764

764-
try testing.expect(try setupZsh(res.path, &env));
765+
const command = try setupZsh(alloc, .{ .shell = "zsh" }, res.path, &env);
766+
try testing.expectEqualStrings("zsh", command.?.shell);
765767
try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?);
766768
try testing.expect(env.get("GHOSTTY_ZSH_ZDOTDIR") == null);
767769
}
768770

769771
test "zsh: ZDOTDIR" {
770772
const testing = std.testing;
771773

774+
var arena = ArenaAllocator.init(testing.allocator);
775+
defer arena.deinit();
776+
const alloc = arena.allocator();
777+
772778
var res: TmpResourcesDir = try .init(testing.allocator, .zsh);
773779
defer res.deinit();
774780

@@ -777,7 +783,8 @@ test "zsh: ZDOTDIR" {
777783

778784
try env.put("ZDOTDIR", "$HOME/.config/zsh");
779785

780-
try testing.expect(try setupZsh(res.path, &env));
786+
const command = try setupZsh(alloc, .{ .shell = "zsh" }, res.path, &env);
787+
try testing.expectEqualStrings("zsh", command.?.shell);
781788
try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?);
782789
try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?);
783790
}
@@ -797,7 +804,7 @@ test "zsh: missing resources" {
797804
var env = EnvMap.init(alloc);
798805
defer env.deinit();
799806

800-
try testing.expect(!try setupZsh(resources_dir, &env));
807+
try testing.expect(try setupZsh(alloc, .{ .shell = "zsh" }, resources_dir, &env) == null);
801808
try testing.expectEqual(0, env.count());
802809
}
803810

0 commit comments

Comments
 (0)