Skip to content

Commit

Permalink
core: add env config option
Browse files Browse the repository at this point in the history
Fixes ghostty-org#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 committed Jan 22, 2025
1 parent ddf7173 commit 8ec2291
Show file tree
Hide file tree
Showing 5 changed files with 237 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 @@ -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",
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 = Config.RepeatableStringMap;
pub const RepeatablePath = Config.RepeatablePath;
pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures;
pub const WindowPaddingColor = Config.WindowPaddingColor;
Expand Down
32 changes: 32 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");
pub const RepeatableStringMap = @import("RepeatableStringMap.zig");

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

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

0 comments on commit 8ec2291

Please sign in to comment.