diff --git a/src/Surface.zig b/src/Surface.zig index b81a45ecb4..98c344927e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -531,6 +531,7 @@ pub fn init( var io_exec = try termio.Exec.init(alloc, .{ .command = command, .env = env, + .env_override = config.env, .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .working_directory = config.@"working-directory", diff --git a/src/config.zig b/src/config.zig index 75dbaae02b..675cf404e4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -27,6 +27,7 @@ pub const OptionAsAlt = Config.OptionAsAlt; pub const RepeatableCodepointMap = Config.RepeatableCodepointMap; pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; +pub const RepeatableStringMap = Config.RepeatableStringMap; pub const RepeatablePath = Config.RepeatablePath; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const WindowPaddingColor = Config.WindowPaddingColor; diff --git a/src/config/Config.zig b/src/config/Config.zig index d191de53a8..2a788248f3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -34,6 +34,7 @@ const KeyValue = @import("key.zig").Value; const ErrorList = @import("ErrorList.zig"); const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); +pub const RepeatableStringMap = @import("RepeatableStringMap.zig"); const log = std.log.scoped(.config); @@ -735,6 +736,42 @@ command: ?[]const u8 = null, /// @"initial-command": ?[]const u8 = null, +/// Extra environment variables to pass to commands launched in a terminal +/// surface. The format is `env=KEY=VALUE`. +/// +/// `env = foo=bar` +/// `env = bar=baz` +/// +/// Setting `env` to an empty string will reset the entire map to default +/// (empty). +/// +/// `env =` +/// +/// Setting a key to an empty string will remove that particular key and +/// corresponding value from the map. +/// +/// `env = foo=bar` +/// `env = foo=` +/// +/// will result in `foo` not being passed to the launched commands. +/// +/// Setting a key multiple times will overwrite previous entries. +/// +/// `env = foo=bar` +/// `env = foo=baz` +/// +/// will result in `foo=baz` being passed to the launched commands. +/// +/// These environment variables will override any existing environment +/// variables set by Ghostty. For example, if you set `GHOSTTY_RESOURCES_DIR` +/// then the value you set here will override the value Ghostty typically +/// automatically injects. +/// +/// These environment variables _will not_ be passed to commands run by Ghostty +/// for other purposes, like `open` or `xdg-open` used to open URLs in your +/// browser. +env: RepeatableStringMap = .{}, + /// If true, keep the terminal open after the command exits. Normally, the /// terminal window closes when the running command (such as a shell) exits. /// With this true, the terminal window will stay open until any keypress is diff --git a/src/config/RepeatableStringMap.zig b/src/config/RepeatableStringMap.zig new file mode 100644 index 0000000000..6f143e95d7 --- /dev/null +++ b/src/config/RepeatableStringMap.zig @@ -0,0 +1,198 @@ +/// RepeatableStringMap is a key/value that can be repeated to accumulate a +/// string map. This isn't called "StringMap" because I find that sometimes +/// leads to confusion that it _accepts_ a map such as JSON dict. +const RepeatableStringMap = @This(); +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const formatterpkg = @import("formatter.zig"); + +const Map = std.ArrayHashMapUnmanaged( + [:0]const u8, + [:0]const u8, + std.array_hash_map.StringContext, + true, +); + +// Allocator for the list is the arena for the parent config. +map: Map = .{}, + +pub fn parseCLI( + self: *RepeatableStringMap, + alloc: Allocator, + input: ?[]const u8, +) !void { + const value = input orelse return error.ValueRequired; + + // Empty value resets the list. We don't need to free our values because + // the allocator used is always an arena. + if (value.len == 0) { + self.map.clearRetainingCapacity(); + return; + } + + const index = std.mem.indexOfScalar( + u8, + value, + '=', + ) orelse return error.ValueRequired; + + const key = std.mem.trim(u8, value[0..index], &std.ascii.whitespace); + const val = std.mem.trim(u8, value[index + 1 ..], &std.ascii.whitespace); + + const key_copy = try alloc.dupeZ(u8, key); + errdefer alloc.free(key_copy); + + // Empty value removes the key from the map. + if (val.len == 0) { + _ = self.map.orderedRemove(key_copy); + alloc.free(key_copy); + return; + } + + const val_copy = try alloc.dupeZ(u8, val); + errdefer alloc.free(val_copy); + + try self.map.put(alloc, key_copy, val_copy); +} + +/// Deep copy of the struct. Required by Config. +pub fn clone( + self: *const RepeatableStringMap, + alloc: Allocator, +) Allocator.Error!RepeatableStringMap { + var map: Map = .{}; + try map.ensureTotalCapacity(alloc, self.map.count()); + + errdefer { + var it = map.iterator(); + while (it.next()) |entry| { + alloc.free(entry.key_ptr.*); + alloc.free(entry.value_ptr.*); + } + map.deinit(alloc); + } + + var it = self.map.iterator(); + while (it.next()) |entry| { + const key = try alloc.dupeZ(u8, entry.key_ptr.*); + const value = try alloc.dupeZ(u8, entry.value_ptr.*); + map.putAssumeCapacity(key, value); + } + + return .{ .map = map }; +} + +/// The number of items in the map +pub fn count(self: RepeatableStringMap) usize { + return self.map.count(); +} + +/// Iterator over the entries in the map. +pub fn iterator(self: RepeatableStringMap) Map.Iterator { + return self.map.iterator(); +} + +/// Compare if two of our value are requal. Required by Config. +pub fn equal(self: RepeatableStringMap, other: RepeatableStringMap) bool { + if (self.map.count() != other.map.count()) return false; + var it = self.map.iterator(); + while (it.next()) |entry| { + const value = other.map.get(entry.key_ptr.*) orelse return false; + if (!std.mem.eql(u8, entry.value_ptr.*, value)) return false; + } else return true; +} + +/// Used by formatter +pub fn formatEntry(self: RepeatableStringMap, formatter: anytype) !void { + // If no items, we want to render an empty field. + if (self.map.count() == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var it = self.map.iterator(); + while (it.next()) |entry| { + var buf: [256]u8 = undefined; + const value = std.fmt.bufPrint(&buf, "{s}={s}", .{ entry.key_ptr.*, entry.value_ptr.* }) catch |err| switch (err) { + error.NoSpaceLeft => return error.OutOfMemory, + }; + try formatter.formatEntry([]const u8, value); + } +} + +test "RepeatableStringMap: parseCLI" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var map: RepeatableStringMap = .{}; + + try testing.expectError(error.ValueRequired, map.parseCLI(alloc, "A")); + + try map.parseCLI(alloc, "A=B"); + try map.parseCLI(alloc, "B=C"); + try testing.expectEqual(@as(usize, 2), map.count()); + + try map.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), map.count()); + + try map.parseCLI(alloc, "A=B"); + try testing.expectEqual(@as(usize, 1), map.count()); + try map.parseCLI(alloc, "A=C"); + try testing.expectEqual(@as(usize, 1), map.count()); +} + +test "RepeatableStringMap: formatConfig empty" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var list: RepeatableStringMap = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = \n", buf.items); +} + +test "RepeatableStringMap: formatConfig single item" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + var map: RepeatableStringMap = .{}; + try map.parseCLI(alloc, "A=B"); + try map.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = A=B\n", buf.items); + } + { + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + var map: RepeatableStringMap = .{}; + try map.parseCLI(alloc, " A = B "); + try map.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = A=B\n", buf.items); + } +} + +test "RepeatableStringMap: formatConfig multiple items" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + var list: RepeatableStringMap = .{}; + try list.parseCLI(alloc, "A=B"); + try list.parseCLI(alloc, "B = C"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = A=B\na = B=C\n", buf.items); + } +} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 864f2e21c0..5a2d2a5070 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -683,6 +683,7 @@ pub const ThreadData = struct { pub const Config = struct { command: ?[]const u8 = null, env: EnvMap, + env_override: configpkg.RepeatableStringMap = .{}, shell_integration: configpkg.Config.ShellIntegration = .detect, shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{}, working_directory: ?[]const u8 = null, @@ -889,6 +890,15 @@ const Subprocess = struct { log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); } + // Add the environment variables that override any others. + { + var it = cfg.env_override.iterator(); + while (it.next()) |entry| try env.put( + entry.key_ptr.*, + entry.value_ptr.*, + ); + } + // Build our args list const args = args: { const cap = 9; // the most we'll ever use