Skip to content

Commit

Permalink
core: add env config option
Browse files Browse the repository at this point in the history
Fixes #5257

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.
  • Loading branch information
jcollie authored and mitchellh committed Feb 15, 2025
1 parent b975f1e commit c7971b5
Show file tree
Hide file tree
Showing 5 changed files with 247 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @import("config/RepeatableStringMap.zig");
pub const RepeatablePath = Config.RepeatablePath;
pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures;
pub const WindowPaddingColor = Config.WindowPaddingColor;
Expand Down
37 changes: 37 additions & 0 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
const RepeatableStringMap = @import("RepeatableStringMap.zig");

const log = std.log.scoped(.config);

Expand Down Expand Up @@ -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
Expand Down
198 changes: 198 additions & 0 deletions src/config/RepeatableStringMap.zig
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 10 additions & 0 deletions src/termio/Exec.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit c7971b5

Please sign in to comment.