diff --git a/build.zig b/build.zig index 0baf6a245..a08602a10 100644 --- a/build.zig +++ b/build.zig @@ -114,6 +114,12 @@ pub fn build(b: *std.Build) !void { "Build and install the benchmark executables.", ) orelse false; + const emit_helpgen = b.option( + bool, + "emit-helpgen", + "Build and install the helpgen executable.", + ) orelse false; + // On NixOS, the built binary from `zig build` needs to patch the rpath // into the built binary for it to be portable across the NixOS system // it was built for. We default this to true if we can detect we're in @@ -177,6 +183,10 @@ pub fn build(b: *std.Build) !void { // We can use wasmtime to test wasm b.enable_wasmtime = true; + // Help exe. This must be run before any dependent executables because + // otherwise the build will be cached without emit. That's clunky but meh. + if (emit_helpgen) addHelp(b, null); + // Add our benchmarks try benchSteps(b, target, optimize, config, emit_bench); @@ -475,7 +485,10 @@ pub fn build(b: *std.Build) !void { } // On Mac we can build the embedding library. This only handles the macOS lib. - if (builtin.target.isDarwin() and target.result.os.tag == .macos) { + if (builtin.target.isDarwin() and + target.result.os.tag == .macos and + config.app_runtime == .none) + { // Create the universal macOS lib. const macos_lib_step, const macos_lib_path = try createMacOSLib( b, @@ -1098,9 +1111,43 @@ fn addDeps( } } + addHelp(b, step); + return static_libs; } +/// Generate help files +fn addHelp( + b: *std.Build, + step_: ?*std.Build.Step.Compile, +) void { + // Our static state between runs. We memoize our help strings + // so that we only execute the help generation once. + const HelpState = struct { + var generated: ?std.Build.LazyPath = null; + }; + + const help_output = HelpState.generated orelse strings: { + const help_exe = b.addExecutable(.{ + .name = "helpgen", + .root_source_file = .{ .path = "src/helpgen.zig" }, + .target = b.host, + }); + if (step_ == null) b.installArtifact(help_exe); + + const help_run = b.addRunArtifact(help_exe); + HelpState.generated = help_run.captureStdOut(); + break :strings HelpState.generated.?; + }; + + if (step_) |step| { + help_output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("help_strings", .{ + .root_source_file = help_output, + }); + } +} + fn benchSteps( b: *std.Build, target: std.Build.ResolvedTarget, diff --git a/src/cli/action.zig b/src/cli/action.zig index f97a5d2cf..2e3afb1c0 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -1,7 +1,9 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const help_strings = @import("help_strings"); const list_fonts = @import("list_fonts.zig"); +const help = @import("help.zig"); const version = @import("version.zig"); const list_keybinds = @import("list_keybinds.zig"); const list_themes = @import("list_themes.zig"); @@ -14,6 +16,9 @@ pub const Action = enum { /// Output the version and exit version, + /// Output help information for the CLI or configuration + help, + /// List available fonts @"list-fonts", @@ -35,6 +40,9 @@ pub const Action = enum { InvalidAction, }; + /// This should be returned by actions that want to print the help text. + pub const help_error = error.ActionHelpRequested; + /// Detect the action from CLI args. pub fn detectCLI(alloc: Allocator) !?Action { var iter = try std.process.argsWithAllocator(alloc); @@ -44,31 +52,90 @@ pub const Action = enum { /// Detect the action from any iterator, used primarily for tests. pub fn detectIter(iter: anytype) Error!?Action { + var pending_help: bool = false; var pending: ?Action = null; while (iter.next()) |arg| { // Special case, --version always outputs the version no // matter what, no matter what other args exist. if (std.mem.eql(u8, arg, "--version")) return .version; + // --help matches "help" but if a subcommand is specified + // then we match the subcommand. + if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + pending_help = true; + continue; + } + // Commands must start with "+" if (arg.len == 0 or arg[0] != '+') continue; if (pending != null) return Error.MultipleActions; pending = std.meta.stringToEnum(Action, arg[1..]) orelse return Error.InvalidAction; } + // If we have an action, we always return that action, even if we've + // seen "--help" or "-h" because the action may have its own help text. + if (pending != null) return pending; + + // If we've seen "--help" or "-h" then we return the help action. + if (pending_help) return .help; + return pending; } /// Run the action. This returns the exit code to exit with. pub fn run(self: Action, alloc: Allocator) !u8 { + return self.runMain(alloc) catch |err| switch (err) { + // If help is requested, then we use some comptime trickery + // to find this action in the help strings and output that. + help_error => err: { + inline for (@typeInfo(Action).Enum.fields) |field| { + // Future note: for now we just output the help text directly + // to stdout. In the future we can style this much prettier + // for all commands by just changing this one place. + + if (std.mem.eql(u8, field.name, @tagName(self))) { + const stdout = std.io.getStdOut().writer(); + const text = @field(help_strings.Action, field.name) ++ "\n"; + stdout.writeAll(text) catch |write_err| { + std.log.warn("failed to write help text: {}\n", .{write_err}); + break :err 1; + }; + + break :err 0; + } + } + + break :err err; + }, + else => err, + }; + } + + fn runMain(self: Action, alloc: Allocator) !u8 { return switch (self) { - .version => try version.run(), + .version => try version.run(alloc), + .help => try help.run(alloc), .@"list-fonts" => try list_fonts.run(alloc), .@"list-keybinds" => try list_keybinds.run(alloc), .@"list-themes" => try list_themes.run(alloc), .@"list-colors" => try list_colors.run(alloc), }; } + + /// Returns the filename associated with an action. This is a relative + /// path from the root src/ directory. + pub fn file(comptime self: Action) []const u8 { + comptime { + const filename = filename: { + const tag = @tagName(self); + var filename: [tag.len]u8 = undefined; + _ = std.mem.replace(u8, tag, "-", "_", &filename); + break :filename &filename; + }; + + return "cli/" ++ filename ++ ".zig"; + } + } }; test "parse action none" { diff --git a/src/cli/args.zig b/src/cli/args.zig index c226493f9..773457cf8 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -77,6 +77,17 @@ pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void { if (!try dst.parseManuallyHook(arena_alloc, arg, iter)) return; } + // If the destination supports help then we check for it, call + // the help function and return. + if (@hasDecl(T, "help")) { + if (mem.eql(u8, arg, "--help") or + mem.eql(u8, arg, "-h")) + { + try dst.help(); + return; + } + } + if (mem.startsWith(u8, arg, "--")) { var key: []const u8 = arg[2..]; const value: ?[]const u8 = value: { diff --git a/src/cli/help.zig b/src/cli/help.zig new file mode 100644 index 000000000..e75b1b617 --- /dev/null +++ b/src/cli/help.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const args = @import("args.zig"); +const Action = @import("action.zig").Action; + +// Note that this options struct doesn't implement the `help` decl like +// other actions. That is because the help command is special and wants to +// handle its own logic around help detection. +pub const Options = struct { + /// This must be registered so that it isn't an error to pass `--help` + help: bool = false, + + pub fn deinit(self: Options) void { + _ = self; + } +}; + +/// The `help` command shows general help about Ghostty. You can also +/// specify `--help` or `-h` along with any action such as `+list-themes` +/// to see help for a specific action. +pub fn run(alloc: Allocator) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try std.process.argsWithAllocator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } + + const stdout = std.io.getStdOut().writer(); + try stdout.writeAll( + \\Usage: ghostty [+action] [options] + \\ + \\Run the Ghostty terminal emulator or a specific helper action. + \\ + \\If no `+action` is specified, run the Ghostty terminal emulator. + \\All configuration keys are available as command line options. + \\To specify a configuration key, use the `--=` syntax + \\where key and value are the same format you'd put into a configuration + \\file. For example, `--font-size=12` or `--font-family="Fira Code"`. + \\ + \\To see a list of all available configuration options, please see + \\the `src/config/Config.zig` file. A future update will allow seeing + \\the list of configuration options from the command line. + \\ + \\A special command line argument `-e ` can be used to run + \\the specific command inside the terminal emulator. For example, + \\`ghostty -e top` will run the `top` command inside the terminal. + \\ + \\On macOS, launching the terminal emulator from the CLI is not + \\supported and only actions are supported. + \\ + \\Available actions: + \\ + \\ + ); + + inline for (@typeInfo(Action).Enum.fields) |field| { + try stdout.print(" +{s}\n", .{field.name}); + } + + try stdout.writeAll( + \\ + \\Specify `+ --help` to see the help for a specific action, + \\where `` is one of actions listed below. + \\ + ); + + return 0; +} diff --git a/src/cli/list_colors.zig b/src/cli/list_colors.zig index 2b6dd1d0e..447f70552 100644 --- a/src/cli/list_colors.zig +++ b/src/cli/list_colors.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Action = @import("action.zig").Action; const args = @import("args.zig"); const x11_color = @import("../terminal/main.zig").x11_color; @@ -6,6 +7,12 @@ 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 "list-colors" command is used to list all the named RGB colors in diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index 0e48bf0f9..b49e43a30 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; +const Action = @import("action.zig").Action; const args = @import("args.zig"); const font = @import("../font/main.zig"); @@ -26,6 +27,12 @@ pub const Config = struct { if (self._arena) |arena| arena.deinit(); self.* = undefined; } + + /// Enables "-h" and "--help" to work. + pub fn help(self: Config) !void { + _ = self; + return Action.help_error; + } }; /// The list-fonts command is used to list all the available fonts for Ghostty. diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index 8d9585be9..15df1815e 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -1,6 +1,7 @@ const std = @import("std"); const inputpkg = @import("../input.zig"); const args = @import("args.zig"); +const Action = @import("action.zig").Action; const Arena = std.heap.ArenaAllocator; const Allocator = std.mem.Allocator; const Config = @import("../config/Config.zig"); @@ -13,6 +14,12 @@ 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 "list-keybinds" command is used to list all the available keybinds diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index be3a41c14..98c3859c6 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -1,6 +1,7 @@ const std = @import("std"); const inputpkg = @import("../input.zig"); const args = @import("args.zig"); +const Action = @import("action.zig").Action; const Arena = std.heap.ArenaAllocator; const Allocator = std.mem.Allocator; const Config = @import("../config/Config.zig"); @@ -10,6 +11,12 @@ 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 "list-themes" command is used to list all the available themes diff --git a/src/cli/version.zig b/src/cli/version.zig index b3395f4cd..fb12faa2e 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -1,10 +1,36 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); const xev = @import("xev"); const renderer = @import("../renderer.zig"); +const args = @import("args.zig"); +const Action = @import("action.zig").Action; + +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 `version` command is used to display information +/// about Ghostty. +pub fn run(alloc: Allocator) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try std.process.argsWithAllocator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } -pub fn run() !u8 { const stdout = std.io.getStdOut().writer(); try stdout.print("Ghostty {s}\n\n", .{build_config.version_string}); try stdout.print("Build Config\n", .{}); diff --git a/src/helpgen.zig b/src/helpgen.zig new file mode 100644 index 000000000..4447346f4 --- /dev/null +++ b/src/helpgen.zig @@ -0,0 +1,167 @@ +//! This program is used to generate the help strings from the configuration +//! file and CLI actions for Ghostty. These can then be used to generate +//! help, docs, website, etc. + +const std = @import("std"); +const ziglyph = @import("ziglyph"); +const Config = @import("config/Config.zig"); +const Action = @import("cli/action.zig").Action; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const alloc = gpa.allocator(); + + const stdout = std.io.getStdOut().writer(); + try stdout.writeAll( + \\// THIS FILE IS AUTO GENERATED + \\ + \\ + ); + + try genConfig(alloc, stdout); + try genActions(alloc, stdout); +} + +fn genConfig(alloc: std.mem.Allocator, writer: anytype) !void { + var ast = try std.zig.Ast.parse(alloc, @embedFile("config/Config.zig"), .zig); + defer ast.deinit(alloc); + + try writer.writeAll( + \\/// Configuration help + \\pub const Config = struct { + \\ + \\ + ); + + inline for (@typeInfo(Config).Struct.fields) |field| { + if (field.name[0] == '_') continue; + try genConfigField(alloc, writer, ast, field.name); + } + + try writer.writeAll("};\n"); +} + +fn genConfigField( + alloc: std.mem.Allocator, + writer: anytype, + ast: std.zig.Ast, + comptime field: []const u8, +) !void { + const tokens = ast.tokens.items(.tag); + for (tokens, 0..) |token, i| { + // We only care about identifiers that are preceded by doc comments. + if (token != .identifier) continue; + if (tokens[i - 1] != .doc_comment) continue; + + // Identifier may have @"" so we strip that. + const name = ast.tokenSlice(@intCast(i)); + const key = if (name[0] == '@') name[2 .. name.len - 1] else name; + if (!std.mem.eql(u8, key, field)) continue; + + const comment = try extractDocComments(alloc, ast, @intCast(i - 1), tokens); + try writer.writeAll("pub const "); + try writer.writeAll(name); + try writer.writeAll(": [:0]const u8 = \n"); + try writer.writeAll(comment); + try writer.writeAll("\n"); + break; + } +} + +fn genActions(alloc: std.mem.Allocator, writer: anytype) !void { + try writer.writeAll( + \\ + \\/// Actions help + \\pub const Action = struct { + \\ + \\ + ); + + inline for (@typeInfo(Action).Enum.fields) |field| { + const action_file = comptime action_file: { + const action = @field(Action, field.name); + break :action_file action.file(); + }; + + var ast = try std.zig.Ast.parse(alloc, @embedFile(action_file), .zig); + defer ast.deinit(alloc); + const tokens: []std.zig.Token.Tag = ast.tokens.items(.tag); + + for (tokens, 0..) |token, i| { + // We're looking for a function named "run". + if (token != .keyword_fn) continue; + if (!std.mem.eql(u8, ast.tokenSlice(@intCast(i + 1)), "run")) continue; + + // The function must be preceded by a doc comment. + if (tokens[i - 2] != .doc_comment) { + std.debug.print( + "doc comment must be present on run function of the {s} action!", + .{field.name}, + ); + std.process.exit(1); + } + + const comment = try extractDocComments(alloc, ast, @intCast(i - 2), tokens); + try writer.writeAll("pub const @\""); + try writer.writeAll(field.name); + try writer.writeAll("\" = \n"); + try writer.writeAll(comment); + try writer.writeAll("\n\n"); + break; + } + } + + try writer.writeAll("};\n"); +} + +fn extractDocComments( + alloc: std.mem.Allocator, + ast: std.zig.Ast, + index: std.zig.Ast.TokenIndex, + tokens: []std.zig.Token.Tag, +) ![]const u8 { + // Find the first index of the doc comments. The doc comments are + // always stacked on top of each other so we can just go backwards. + const start_idx: usize = start_idx: for (0..index) |i| { + const reverse_i = index - i - 1; + const token = tokens[reverse_i]; + if (token != .doc_comment) break :start_idx reverse_i + 1; + } else unreachable; + + // Go through and build up the lines. + var lines = std.ArrayList([]const u8).init(alloc); + defer lines.deinit(); + for (start_idx..index + 1) |i| { + const token = tokens[i]; + if (token != .doc_comment) break; + try lines.append(ast.tokenSlice(@intCast(i))[3..]); + } + + // Convert the lines to a multiline string. + var buffer = std.ArrayList(u8).init(alloc); + const writer = buffer.writer(); + const prefix = findCommonPrefix(lines); + for (lines.items) |line| { + try writer.writeAll(" \\\\"); + try writer.writeAll(line[@min(prefix, line.len)..]); + try writer.writeAll("\n"); + } + try writer.writeAll(";\n"); + + return buffer.toOwnedSlice(); +} + +fn findCommonPrefix(lines: std.ArrayList([]const u8)) usize { + var m: usize = std.math.maxInt(usize); + for (lines.items) |line| { + var n: usize = std.math.maxInt(usize); + for (line, 0..) |c, i| { + if (c != ' ') { + n = i; + break; + } + } + m = @min(m, n); + } + return m; +}