summaryrefslogtreecommitdiff
path: root/src/cli
diff options
context:
space:
mode:
Diffstat (limited to 'src/cli')
-rw-r--r--src/cli/action.zig6
-rw-r--r--src/cli/args.zig211
-rw-r--r--src/cli/boo.zig2
-rw-r--r--src/cli/edit_config.zig159
-rw-r--r--src/cli/list_themes.zig5
5 files changed, 325 insertions, 58 deletions
diff --git a/src/cli/action.zig b/src/cli/action.zig
index a53e55ef8..009afb4c9 100644
--- a/src/cli/action.zig
+++ b/src/cli/action.zig
@@ -9,6 +9,7 @@ const list_keybinds = @import("list_keybinds.zig");
const list_themes = @import("list_themes.zig");
const list_colors = @import("list_colors.zig");
const list_actions = @import("list_actions.zig");
+const edit_config = @import("edit_config.zig");
const show_config = @import("show_config.zig");
const validate_config = @import("validate_config.zig");
const crash_report = @import("crash_report.zig");
@@ -40,6 +41,9 @@ pub const Action = enum {
/// List keybind actions
@"list-actions",
+ /// Edit the config file in the configured terminal editor.
+ @"edit-config",
+
/// Dump the config to stdout
@"show-config",
@@ -151,6 +155,7 @@ pub const Action = enum {
.@"list-themes" => try list_themes.run(alloc),
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
+ .@"edit-config" => try edit_config.run(alloc),
.@"show-config" => try show_config.run(alloc),
.@"validate-config" => try validate_config.run(alloc),
.@"crash-report" => try crash_report.run(alloc),
@@ -187,6 +192,7 @@ pub const Action = enum {
.@"list-themes" => list_themes.Options,
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
+ .@"edit-config" => edit_config.Options,
.@"show-config" => show_config.Options,
.@"validate-config" => validate_config.Options,
.@"crash-report" => crash_report.Options,
diff --git a/src/cli/args.zig b/src/cli/args.zig
index 4860cdd74..1af74df69 100644
--- a/src/cli/args.zig
+++ b/src/cli/args.zig
@@ -40,11 +40,14 @@ pub const Error = error{
/// "DiagnosticList" and any diagnostic messages will be added to that list.
/// When diagnostics are present, only allocation errors will be returned.
///
-/// If the destination type has a decl "renamed", it must be of type
-/// std.StaticStringMap([]const u8) and contains a mapping from the old
-/// field name to the new field name. This is used to allow renaming fields
-/// while still supporting the old name. If a renamed field is set, parsing
-/// will automatically set the new field name.
+/// If the destination type has a decl "compatibility", it must be of type
+/// std.StaticStringMap(CompatibilityHandler(T)), and it will be used to
+/// handle backwards compatibility for fields with the given name. The
+/// field name doesn't need to exist (so you can setup compatibility for
+/// removed fields). The value is a function that will be called when
+/// all other parsing fails for that field. If a field changes such that
+/// the old values would NOT error, then the caller should handle that
+/// downstream after parsing is done, not through this method.
///
/// Note: If the arena is already non-null, then it will be used. In this
/// case, in the case of an error some memory might be leaked into the arena.
@@ -57,24 +60,6 @@ pub fn parse(
const info = @typeInfo(T);
assert(info == .@"struct");
- comptime {
- // Verify all renamed fields are valid (source does not exist,
- // destination does exist).
- if (@hasDecl(T, "renamed")) {
- for (T.renamed.keys(), T.renamed.values()) |key, value| {
- if (@hasField(T, key)) {
- @compileLog(key);
- @compileError("renamed field source exists");
- }
-
- if (!@hasField(T, value)) {
- @compileLog(value);
- @compileError("renamed field destination does not exist");
- }
- }
- }
- }
-
// Make an arena for all our allocations if we support it. Otherwise,
// use an allocator that always fails. If the arena is already set on
// the config, then we reuse that. See memory note in parse docs.
@@ -84,7 +69,7 @@ pub fn parse(
// If the arena is unset, we create it. We mark that we own it
// only so that we can clean it up on error.
if (dst._arena == null) {
- dst._arena = ArenaAllocator.init(alloc);
+ dst._arena = .init(alloc);
arena_owned = true;
}
@@ -147,7 +132,23 @@ pub fn parse(
break :value null;
};
- parseIntoField(T, arena_alloc, dst, key, value) catch |err| {
+ parseIntoField(T, arena_alloc, dst, key, value) catch |err| err: {
+ // If we get an error parsing a field, then we try to fall
+ // back to compatibility handlers if able.
+ if (@hasDecl(T, "compatibility")) {
+ // If we have a compatibility handler for this key, then
+ // we call it and see if it handles the error.
+ if (T.compatibility.get(key)) |handler| {
+ if (handler(dst, arena_alloc, key, value)) {
+ log.info(
+ "compatibility handler for {s} handled error, you may be using a deprecated field: {}",
+ .{ key, err },
+ );
+ break :err;
+ }
+ }
+ }
+
if (comptime !canTrackDiags(T)) return err;
// The error set is dependent on comptime T, so we always add
@@ -177,6 +178,58 @@ pub fn parse(
}
}
+/// The function type for a compatibility handler. The compatibility
+/// handler is documented in the `parse` function documentation.
+///
+/// The function type should return bool if the compatibility was
+/// handled, and false otherwise. If false is returned then the
+/// naturally occurring error will continue to be processed as if
+/// this compatibility handler was not present.
+///
+/// Compatibility handlers aren't allowed to return errors because
+/// they're generally only called in error cases, so we already have
+/// an error message to show users. If there is an error in handling
+/// the compatibility, then the handler should return false.
+pub fn CompatibilityHandler(comptime T: type) type {
+ return *const fn (
+ dst: *T,
+ alloc: Allocator,
+ key: []const u8,
+ value: ?[]const u8,
+ ) bool;
+}
+
+/// Convenience function to create a compatibility handler that
+/// renames a field from `from` to `to`.
+pub fn compatibilityRenamed(
+ comptime T: type,
+ comptime to: []const u8,
+) CompatibilityHandler(T) {
+ comptime assert(@hasField(T, to));
+
+ return (struct {
+ fn compat(
+ dst: *T,
+ alloc: Allocator,
+ key: []const u8,
+ value: ?[]const u8,
+ ) bool {
+ _ = key;
+
+ parseIntoField(T, alloc, dst, to, value) catch |err| {
+ log.warn("error parsing renamed field {s}: {}", .{
+ to,
+ err,
+ });
+
+ return false;
+ };
+
+ return true;
+ }
+ }).compat;
+}
+
fn formatValueRequired(
comptime T: type,
arena_alloc: std.mem.Allocator,
@@ -401,20 +454,10 @@ pub fn parseIntoField(
}
}
- // Unknown field, is the field renamed?
- if (@hasDecl(T, "renamed")) {
- for (T.renamed.keys(), T.renamed.values()) |old, new| {
- if (mem.eql(u8, old, key)) {
- try parseIntoField(T, alloc, dst, new, value);
- return;
- }
- }
- }
-
return error.InvalidField;
}
-fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
+pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
const info = @typeInfo(T).@"union";
assert(@typeInfo(info.tag_type.?) == .@"enum");
@@ -481,7 +524,7 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
// Keep track of which fields were set so we can error if a required
// field was not set.
const FieldSet = std.StaticBitSet(info.fields.len);
- var fields_set: FieldSet = FieldSet.initEmpty();
+ var fields_set: FieldSet = .initEmpty();
// We split each value by ","
var iter = std.mem.splitSequence(u8, v, ",");
@@ -752,6 +795,77 @@ test "parse: diagnostic location" {
}
}
+test "parse: compatibility handler" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: bool = false,
+ _arena: ?ArenaAllocator = null,
+
+ pub const compatibility: std.StaticStringMap(
+ CompatibilityHandler(@This()),
+ ) = .initComptime(&.{
+ .{ "a", compat },
+ });
+
+ fn compat(
+ self: *@This(),
+ alloc: Allocator,
+ key: []const u8,
+ value: ?[]const u8,
+ ) bool {
+ _ = alloc;
+ if (std.mem.eql(u8, key, "a")) {
+ if (value) |v| {
+ if (mem.eql(u8, v, "yuh")) {
+ self.a = true;
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--a=yuh",
+ );
+ defer iter.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expect(data._arena != null);
+ try testing.expect(data.a);
+}
+
+test "parse: compatibility renamed" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: bool = false,
+ b: bool = false,
+ _arena: ?ArenaAllocator = null,
+
+ pub const compatibility: std.StaticStringMap(
+ CompatibilityHandler(@This()),
+ ) = .initComptime(&.{
+ .{ "old", compatibilityRenamed(@This(), "a") },
+ });
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--old=true --b=true",
+ );
+ defer iter.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expect(data._arena != null);
+ try testing.expect(data.a);
+ try testing.expect(data.b);
+}
+
test "parseIntoField: ignore underscore-prefixed fields" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
@@ -1090,6 +1204,7 @@ test "parseIntoField: tagged union" {
b: u8,
c: void,
d: []const u8,
+ e: [:0]const u8,
} = undefined,
} = .{};
@@ -1108,6 +1223,10 @@ test "parseIntoField: tagged union" {
// Set string field
try parseIntoField(@TypeOf(data), alloc, &data, "value", "d:hello");
try testing.expectEqualStrings("hello", data.value.d);
+
+ // Set sentinel string field
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "e:hello");
+ try testing.expectEqualStrings("hello", data.value.e);
}
test "parseIntoField: tagged union unknown filed" {
@@ -1171,24 +1290,6 @@ test "parseIntoField: tagged union missing tag" {
);
}
-test "parseIntoField: renamed field" {
- const testing = std.testing;
- var arena = ArenaAllocator.init(testing.allocator);
- defer arena.deinit();
- const alloc = arena.allocator();
-
- var data: struct {
- a: []const u8,
-
- const renamed = std.StaticStringMap([]const u8).initComptime(&.{
- .{ "old", "a" },
- });
- } = undefined;
-
- try parseIntoField(@TypeOf(data), alloc, &data, "old", "42");
- try testing.expectEqualStrings("42", data.a);
-}
-
/// An iterator that considers its location to be CLI args. It
/// iterates through an underlying iterator and increments a counter
/// to track the current CLI arg index.
diff --git a/src/cli/boo.zig b/src/cli/boo.zig
index 7ecbf79fb..47c8ab741 100644
--- a/src/cli/boo.zig
+++ b/src/cli/boo.zig
@@ -176,7 +176,7 @@ const Boo = struct {
pub fn run(gpa: Allocator) !u8 {
// Disable on non-desktop systems.
switch (builtin.os.tag) {
- .windows, .macos, .linux => {},
+ .windows, .macos, .linux, .freebsd => {},
else => return 1,
}
diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig
new file mode 100644
index 000000000..3be88e090
--- /dev/null
+++ b/src/cli/edit_config.zig
@@ -0,0 +1,159 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = std.debug.assert;
+const args = @import("args.zig");
+const Allocator = std.mem.Allocator;
+const Action = @import("action.zig").Action;
+const configpkg = @import("../config.zig");
+const internal_os = @import("../os/main.zig");
+const Config = configpkg.Config;
+
+pub const Options = struct {
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `edit-config` command opens the Ghostty configuration file in the
+/// editor specified by the `$VISUAL` or `$EDITOR` environment variables.
+///
+/// IMPORTANT: This command will not reload the configuration after
+/// editing. You will need to manually reload the configuration using the
+/// application menu, configured keybind, or by restarting Ghostty. We
+/// plan to auto-reload in the future, but Ghostty isn't capable of
+/// this yet.
+///
+/// The filepath opened is the default user-specific configuration
+/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`.
+/// On macOS, this may also be located at
+/// `~/Library/Application Support/com.mitchellh.ghostty/config`.
+/// On macOS, whichever path exists and is non-empty will be prioritized,
+/// prioritizing the Application Support directory if neither are
+/// non-empty.
+///
+/// This command prefers the `$VISUAL` environment variable over `$EDITOR`,
+/// if both are set. If neither are set, it will print an error
+/// and exit.
+pub fn run(alloc: Allocator) !u8 {
+ // Implementation note (by @mitchellh): I do proper memory cleanup
+ // throughout this command, even though we plan on doing `exec`.
+ // I do this out of good hygiene in case we ever change this to
+ // not using `exec` anymore and because this command isn't performance
+ // critical where setting up the defer cleanup is a problem.
+
+ const stderr = std.io.getStdErr().writer();
+
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ try args.parse(Options, alloc, &opts, &iter);
+ }
+
+ // We load the configuration once because that will write our
+ // default configuration files to disk. We don't use the config.
+ var config = try Config.load(alloc);
+ defer config.deinit();
+
+ // Find the preferred path.
+ const path = try Config.preferredDefaultFilePath(alloc);
+ defer alloc.free(path);
+
+ // We don't currently support Windows because we use the exec syscall.
+ if (comptime builtin.os.tag == .windows) {
+ try stderr.print(
+ \\The `ghostty +edit-config` command is not supported on Windows.
+ \\Please edit the configuration file manually at the following path:
+ \\
+ \\{s}
+ \\
+ ,
+ .{path},
+ );
+ return 1;
+ }
+
+ // Get our editor
+ const get_env_: ?internal_os.GetEnvResult = env: {
+ // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
+ if (try internal_os.getenv(alloc, "VISUAL")) |v| {
+ if (v.value.len > 0) break :env v;
+ v.deinit(alloc);
+ }
+
+ if (try internal_os.getenv(alloc, "EDITOR")) |v| {
+ if (v.value.len > 0) break :env v;
+ v.deinit(alloc);
+ }
+
+ break :env null;
+ };
+ defer if (get_env_) |v| v.deinit(alloc);
+ const editor: []const u8 = if (get_env_) |v| v.value else "";
+
+ // If we don't have `$EDITOR` set then we can't do anything
+ // but we can still print a helpful message.
+ if (editor.len == 0) {
+ try stderr.print(
+ \\The $EDITOR or $VISUAL environment variable is not set or is empty.
+ \\This environment variable is required to edit the Ghostty configuration
+ \\via this CLI command.
+ \\
+ \\Please set the environment variable to your preferred terminal
+ \\text editor and try again.
+ \\
+ \\If you prefer to edit the configuration file another way,
+ \\you can find the configuration file at the following path:
+ \\
+ \\
+ ,
+ .{},
+ );
+
+ // Output the path using the OSC8 sequence so that it is linked.
+ try stderr.print(
+ "\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n",
+ .{ path, path },
+ );
+
+ return 1;
+ }
+
+ // We require libc because we want to use std.c.environ for envp
+ // and not have to build that ourselves. We can remove this
+ // limitation later but Ghostty already heavily requires libc
+ // so this is not a big deal.
+ comptime assert(builtin.link_libc);
+
+ const editorZ = try alloc.dupeZ(u8, editor);
+ defer alloc.free(editorZ);
+ const pathZ = try alloc.dupeZ(u8, path);
+ defer alloc.free(pathZ);
+ const err = std.posix.execvpeZ(
+ editorZ,
+ &.{ editorZ, pathZ },
+ std.c.environ,
+ );
+
+ // If we reached this point then exec failed.
+ try stderr.print(
+ \\Failed to execute the editor. Error code={}.
+ \\
+ \\This is usually due to the executable path not existing, invalid
+ \\permissions, or the shell environment not being set up
+ \\correctly.
+ \\
+ \\Editor: {s}
+ \\Path: {s}
+ \\
+ , .{ err, editor, path });
+ return 1;
+}
diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig
index 54f4c0969..e80a92286 100644
--- a/src/cli/list_themes.zig
+++ b/src/cli/list_themes.zig
@@ -77,7 +77,7 @@ const ThemeListElement = struct {
/// Two different directories will be searched for themes.
///
/// The first directory is the `themes` subdirectory of your Ghostty
-/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or
+/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or
/// `~/.config/ghostty/themes`.
///
/// The second directory is the `themes` subdirectory of the Ghostty resources
@@ -115,7 +115,8 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
const stderr = std.io.getStdErr().writer();
const stdout = std.io.getStdOut().writer();
- if (global_state.resources_dir == null)
+ const resources_dir = global_state.resources_dir.app();
+ if (resources_dir == null)
try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++
"that Ghostty is installed correctly.\n", .{});