diff --git a/build.zig b/build.zig index d233bff1f..0c5cfaf41 100644 --- a/build.zig +++ b/build.zig @@ -12,6 +12,7 @@ const terminfo = @import("src/terminfo/main.zig"); const config_vim = @import("src/config/vim.zig"); const config_sublime_syntax = @import("src/config/sublime_syntax.zig"); const fish_completions = @import("src/build/fish_completions.zig"); +const zsh_completions = @import("src/build/zsh_completions.zig"); const build_config = @import("src/build_config.zig"); const BuildConfig = build_config.BuildConfig; const WasmTarget = @import("src/os/wasm/target.zig").Target; @@ -504,6 +505,18 @@ pub fn build(b: *std.Build) !void { }); } + // zsh shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("_ghostty", zsh_completions.zsh_completions); + + b.installDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/zsh/site-functions", + }); + } + // Vim plugin { const wf = b.addWriteFiles(); diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 2ac67bdad..87a82e7ee 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -2,7 +2,7 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); const Action = @import("../cli/action.zig").Action; -const ListFontsConfig = @import("../cli/list_fonts.zig").Config; +const ListFontsOptions = @import("../cli/list_fonts.zig").Options; const ShowConfigOptions = @import("../cli/show_config.zig").Options; const ListKeybindsOptions = @import("../cli/list_keybinds.zig").Options; @@ -100,7 +100,7 @@ fn writeFishCompletions(writer: anytype) !void { try writer.writeAll("\"\n"); } - for (@typeInfo(ListFontsConfig).Struct.fields) |field| { + for (@typeInfo(ListFontsOptions).Struct.fields) |field| { if (field.name[0] == '_') continue; try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +list-fonts\" -l "); try writer.writeAll(field.name); diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig new file mode 100644 index 000000000..78d256ee2 --- /dev/null +++ b/src/build/zsh_completions.zig @@ -0,0 +1,201 @@ +const std = @import("std"); + +const Config = @import("../config/Config.zig"); +const Action = @import("../cli/action.zig").Action; + +/// A zsh completions configuration that contains all the available commands +/// and options. +pub const zsh_completions = comptimeGenerateZshCompletions(); + +fn comptimeGenerateZshCompletions() []const u8 { + comptime { + @setEvalBranchQuota(19000); + var counter = std.io.countingWriter(std.io.null_writer); + try writeZshCompletions(&counter.writer()); + + var buf: [counter.bytes_written]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + try writeZshCompletions(stream.writer()); + const final = buf; + return final[0..stream.getWritten().len]; + } +} + +fn writeZshCompletions(writer: anytype) !void { + try writer.writeAll( + \\#compdef ghostty + \\ + \\_fonts () { + \\ local font_list=$(ghostty +list-fonts | grep -Z '^[A-Z]') + \\ local fonts=(${(f)font_list}) + \\ _describe -t fonts 'fonts' fonts + \\} + \\ + \\_themes() { + \\ local theme_list=$(ghostty +list-themes | sed -E 's/^(.*) \(.*\$/\0/') + \\ local themes=(${(f)theme_list}) + \\ _describe -t themes 'themes' themes + \\} + \\ + ); + + try writer.writeAll("_config() {\n"); + try writer.writeAll(" _arguments \\\n"); + try writer.writeAll(" \"--help\" \\\n"); + try writer.writeAll(" \"--version\" \\\n"); + for (@typeInfo(Config).Struct.fields) |field| { + if (field.name[0] == '_') continue; + try writer.writeAll(" \"--"); + try writer.writeAll(field.name); + try writer.writeAll("=-:::"); + + if (std.mem.startsWith(u8, field.name, "font-family")) + try writer.writeAll("_fonts") + else if (std.mem.eql(u8, "theme", field.name)) + try writer.writeAll("_themes") + else if (std.mem.eql(u8, "working-directory", field.name)) + try writer.writeAll("{_files -/}") + else if (field.type == Config.RepeatablePath) + try writer.writeAll("_files") // todo check if this is needed + else { + try writer.writeAll("("); + switch (@typeInfo(field.type)) { + .Bool => try writer.writeAll("true false"), + .Enum => |info| { + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + }, + .Struct => |info| { + if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + try writer.writeAll(" no-"); + try writer.writeAll(f.name); + } + } else { + //resize-overlay-duration + //keybind + //window-padding-x ...-y + //link + //palette + //background + //foreground + //font-variation* + //font-feature + try writer.writeAll(" "); + } + }, + else => try writer.writeAll(" "), + } + try writer.writeAll(")"); + } + + try writer.writeAll("\" \\\n"); + } + try writer.writeAll("\n}\n\n"); + + try writer.writeAll( + \\_ghostty() { + \\ typeset -A opt_args + \\ local context state line + \\ local opt=('--help' '--version') + \\ + \\ _arguments -C \ + \\ '1:actions:->actions' \ + \\ '*:: :->rest' \ + \\ + \\ if [[ "$line[1]" == "--help" || "$line[1]" == "--version" ]]; then + \\ return + \\ fi + \\ + \\ if [[ "$line[1]" == -* ]]; then + \\ _config + \\ return + \\ fi + \\ + \\ case "$state" in + \\ (actions) + \\ local actions; actions=( + \\ + ); + + { + // how to get 'commands' + var count: usize = 0; + const padding = " "; + for (@typeInfo(Action).Enum.fields) |field| { + try writer.writeAll(padding ++ "'+"); + try writer.writeAll(field.name); + try writer.writeAll("'\n"); + count += 1; + } + } + + try writer.writeAll( + \\ ) + \\ _describe '' opt + \\ _describe -t action 'action' actions + \\ ;; + \\ (rest) + \\ if [[ "$line[2]" == "--help" ]]; then + \\ return + \\ fi + \\ + \\ local help=('--help') + \\ _describe '' help + \\ + \\ case $line[1] in + \\ + ); + { + const padding = " "; + for (@typeInfo(Action).Enum.fields) |field| { + if (std.mem.eql(u8, "help", field.name)) continue; + if (std.mem.eql(u8, "version", field.name)) continue; + + const options = @field(Action, field.name).options(); + // assumes options will never be created with only <_name> members + if (@typeInfo(options).Struct.fields.len == 0) continue; + + try writer.writeAll(padding ++ "(+" ++ field.name ++ ")\n"); + try writer.writeAll(padding ++ " _arguments \\\n"); + for (@typeInfo(options).Struct.fields) |opt| { + if (opt.name[0] == '_') continue; + + try writer.writeAll(padding ++ " '--"); + try writer.writeAll(opt.name); + try writer.writeAll("=-:::"); + switch (@typeInfo(opt.type)) { + .Bool => try writer.writeAll("(true false)"), + .Enum => |info| { + try writer.writeAll("("); + for (info.opts, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll(")"); + }, + else => { + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll("_files"); + } else try writer.writeAll("( )"); + }, + } + try writer.writeAll("' \\\n"); + } + try writer.writeAll(padding ++ ";;\n"); + } + } + try writer.writeAll( + \\ esac + \\ ;; + \\ esac + \\} + \\ + \\_ghostty "$@" + \\ + ); +} diff --git a/src/cli/action.zig b/src/cli/action.zig index 1da0c0609..2f4b63638 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -163,6 +163,26 @@ pub const Action = enum { return "cli/" ++ filename ++ ".zig"; } } + + /// Returns the options of action. Supports generating shell completions + /// without duplicating the mapping from Action to relevant Option + /// @import(..) declaration. + pub fn options(comptime self: Action) type { + comptime { + return switch (self) { + .version => version.Options, + .help => help.Options, + .@"list-fonts" => list_fonts.Options, + .@"list-keybinds" => list_keybinds.Options, + .@"list-themes" => list_themes.Options, + .@"list-colors" => list_colors.Options, + .@"list-actions" => list_actions.Options, + .@"show-config" => show_config.Options, + .@"validate-config" => validate_config.Options, + .@"crash-report" => crash_report.Options, + }; + } + } }; test "parse action none" { diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index aba596b64..9d1f34cd1 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -7,7 +7,7 @@ const font = @import("../font/main.zig"); const log = std.log.scoped(.list_fonts); -pub const Config = struct { +pub const Options = struct { /// This is set by the CLI parser for deinit. _arena: ?ArenaAllocator = null, @@ -23,13 +23,13 @@ pub const Config = struct { bold: bool = false, italic: bool = false, - pub fn deinit(self: *Config) void { + pub fn deinit(self: *Options) void { if (self._arena) |arena| arena.deinit(); self.* = undefined; } /// Enables "-h" and "--help" to work. - pub fn help(self: Config) !void { + pub fn help(self: Options) !void { _ = self; return Action.help_error; } @@ -59,9 +59,9 @@ pub fn run(alloc: Allocator) !u8 { } fn runArgs(alloc_gpa: Allocator, argsIter: anytype) !u8 { - var config: Config = .{}; + var config: Options = .{}; defer config.deinit(); - try args.parse(Config, alloc_gpa, &config, argsIter); + try args.parse(Options, alloc_gpa, &config, argsIter); // Use an arena for all our memory allocs var arena = ArenaAllocator.init(alloc_gpa); diff --git a/src/cli/version.zig b/src/cli/version.zig index 26d5dcc74..259cb7453 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -7,6 +7,8 @@ const xev = @import("xev"); const renderer = @import("../renderer.zig"); const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void; +pub const Options = struct {}; + /// The `version` command is used to display information about Ghostty. pub fn run(alloc: Allocator) !u8 { _ = alloc;