diff options
Diffstat (limited to 'src/cli')
| -rw-r--r-- | src/cli/action.zig | 6 | ||||
| -rw-r--r-- | src/cli/args.zig | 211 | ||||
| -rw-r--r-- | src/cli/boo.zig | 2 | ||||
| -rw-r--r-- | src/cli/edit_config.zig | 159 | ||||
| -rw-r--r-- | src/cli/list_themes.zig | 5 |
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", .{}); |
