diff --git a/src/Surface.zig b/src/Surface.zig index 138aa2ea22..361cbdfc25 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -522,6 +522,7 @@ pub fn init( // Initialize our IO backend var io_exec = try termio.Exec.init(alloc, .{ .command = command, + .env = 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 fd0f58669f..fcfdcddaca 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); @@ -730,6 +731,37 @@ command: ?[]const u8 = null, /// @"initial-command": ?[]const u8 = null, +/// Specify 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 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..7d71ab283a --- /dev/null +++ b/src/config/RepeatableStringMap.zig @@ -0,0 +1,190 @@ +/// 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 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: std.mem.Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + + // Empty value resets the list + if (value.len == 0) { + var it = self.map.iterator(); + while (it.next()) |entry| { + alloc.free(entry.key_ptr.*); + alloc.free(entry.value_ptr.*); + } + 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); + + if (val.len == 0) { + if (self.map.fetchOrderedRemove(key_copy)) |entry| { + alloc.free(entry.key); + alloc.free(entry.value); + } + alloc.free(key_copy); + return; + } + + const val_copy = try alloc.dupeZ(u8, val); + errdefer alloc.free(val_copy); + + if (try self.map.fetchPut(alloc, key_copy, val_copy)) |entry| { + alloc.free(key_copy); + alloc.free(entry.value); + } +} + +/// Deep copy of the struct. Required by Config. +pub fn clone(self: *const RepeatableStringMap, alloc: std.mem.Allocator) std.mem.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 e320152ec1..33a8dcf4e5 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -673,6 +673,7 @@ pub const ThreadData = struct { pub const Config = struct { command: ?[]const u8 = null, + env: configpkg.RepeatableStringMap = .{}, shell_integration: configpkg.Config.ShellIntegration = .detect, shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{}, working_directory: ?[]const u8 = null, @@ -867,6 +868,18 @@ const Subprocess = struct { env.remove("GSK_RENDERER"); } + // Add environment variables from the config + { + var it = cfg.env.iterator(); + while (it.next()) |entry| { + const key_copy = try alloc.dupe(u8, entry.key_ptr.*); + errdefer alloc.free(key_copy); + const value_copy = try alloc.dupe(u8, entry.value_ptr.*); + errdefer alloc.free(value_copy); + try env.put(key_copy, value_copy); + } + } + // Setup our shell integration, if we can. const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: { const default_shell_command = cfg.command orelse switch (builtin.os.tag) {