os/shell: introduce ShellCommandBuilder

This builder is an efficient way to construct space-separated shell
command strings.

We use it in setupBash to avoid using an intermediate array of arguments
to construct our bash command line.
pull/9881/head
Jon Parise 2025-12-11 21:02:42 -05:00
parent 2448a90c30
commit 04fecd7c07
2 changed files with 87 additions and 10 deletions

View File

@ -1,7 +1,84 @@
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
/// Builder for constructing space-separated shell command strings.
/// Uses a caller-provided allocator (typically with stackFallback).
pub const ShellCommandBuilder = struct {
buffer: std.Io.Writer.Allocating,
pub fn init(allocator: Allocator) ShellCommandBuilder {
return .{ .buffer = .init(allocator) };
}
pub fn deinit(self: *ShellCommandBuilder) void {
self.buffer.deinit();
}
/// Append an argument to the command with automatic space separation.
pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void {
if (arg.len == 0) return;
if (self.buffer.written().len > 0) {
try self.buffer.writer.writeByte(' ');
}
try self.buffer.writer.writeAll(arg);
}
/// Get the final null-terminated command string, transferring ownership to caller.
/// Calling deinit() after this is safe but unnecessary.
pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 {
return try self.buffer.toOwnedSliceSentinel(0);
}
};
test ShellCommandBuilder {
// Empty command
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try testing.expectEqualStrings("", cmd.buffer.written());
}
// Single arg
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try testing.expectEqualStrings("bash", cmd.buffer.written());
}
// Multiple args
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try cmd.appendArg("--posix");
try cmd.appendArg("-l");
try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written());
}
// Empty arg
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try cmd.appendArg("");
try testing.expectEqualStrings("bash", cmd.buffer.written());
}
// toOwnedSlice
{
var cmd = ShellCommandBuilder.init(testing.allocator);
try cmd.appendArg("bash");
try cmd.appendArg("--posix");
const result = try cmd.toOwnedSlice();
defer testing.allocator.free(result);
try testing.expectEqualStrings("bash --posix", result);
try testing.expectEqual(@as(u8, 0), result[result.len]);
}
}
/// Writer that escapes characters that shells treat specially to reduce the
/// risk of injection attacks or other such weirdness. Specifically excludes
/// linefeeds so that they can be used to delineate lists of file paths.

View File

@ -259,8 +259,9 @@ fn setupBash(
resource_dir: []const u8,
env: *EnvMap,
) !?config.Command {
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2);
defer args.deinit(alloc);
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.
@ -269,9 +270,9 @@ fn setupBash(
// Start accumulating arguments with the executable and initial flags.
if (iter.next()) |exe| {
try args.append(alloc, try alloc.dupeZ(u8, exe));
try cmd.appendArg(exe);
} else return null;
try args.append(alloc, "--posix");
try cmd.appendArg("--posix");
// Stores the list of intercepted command line flags that will be passed
// to our shell integration script: --norc --noprofile
@ -304,17 +305,17 @@ fn setupBash(
if (std.mem.indexOfScalar(u8, arg, 'c') != null) {
return null;
}
try args.append(alloc, try alloc.dupeZ(u8, arg));
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 args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
while (iter.next()) |remaining_arg| {
try args.append(alloc, try alloc.dupeZ(u8, remaining_arg));
try cmd.appendArg(remaining_arg);
}
break;
} else {
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
}
}
try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]);
@ -352,8 +353,7 @@ fn setupBash(
);
try env.put("ENV", integ_dir);
// Join the accumulated arguments to form the final command string.
return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) };
return .{ .shell = try cmd.toOwnedSlice() };
}
test "bash" {