Resolves RV14 (docs/rewrite/notes/review-rv14-codex.md, final-Z17.md): - +show-face --cp parses as u21 (new U21 field type): out-of-range values give an invalid-value diagnostic and exit 1 instead of a panic; --string accepts raw bytes and fails with error.InvalidUtf8 after the codepoint lookup like Zig. - CLI actions write through ZigWriter, a model of Zig 0.15's buffered File.writer (fixed buffer, drain on overflow, explicit flush), so stdout/stderr interleave in Zig's flush order (+show-face, help, version, validate-config, show-config, list-*, ssh-cache, crash-report, edit-config, new-window). - Zig 0.15.2 std.fs.path.isAbsolute port (Windows: \theme and /theme are absolute) replaces Path::is_absolute at every Zig isAbsolute site. - io_uring is available iff a 256-entry ring can be created, like libxev; the reactor falls back to IORING_OP_TIMEOUT without EXT_ARG. - Windows basename (`λ:theme`) verified against 0.15.2 (test added). Test Plan: - cargo test --workspace --no-fail-fast - diff +show-face outputs (pipe) against /usr/bin/ghostty
388 lines
13 KiB
Rust
388 lines
13 KiB
Rust
//! The CLI root (ZIG/src/cli/ghostty.zig): the [`Action`] enum, `+action`
|
|
//! dispatch and per-action `--help` output.
|
|
|
|
use std::io::Write;
|
|
|
|
use crate::{
|
|
action::{ActionEnum, DetectError, SpecialCase, detect_iter},
|
|
error::Error,
|
|
};
|
|
|
|
/// Special commands that can be invoked via CLI flags. These are all
|
|
/// invoked by using `+<action>` as a CLI flag. The only exception is
|
|
/// "version" which can be invoked additionally with `--version`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum Action {
|
|
/// Output the version and exit
|
|
Version,
|
|
|
|
/// Output help information for the CLI or configuration
|
|
Help,
|
|
|
|
/// List available fonts
|
|
ListFonts,
|
|
|
|
/// List available keybinds
|
|
ListKeybinds,
|
|
|
|
/// List available themes
|
|
ListThemes,
|
|
|
|
/// List named RGB colors
|
|
ListColors,
|
|
|
|
/// List keybind actions
|
|
ListActions,
|
|
|
|
/// Manage SSH terminfo cache for automatic remote host setup
|
|
SshCache,
|
|
|
|
/// Edit the config file in the configured terminal editor.
|
|
EditConfig,
|
|
|
|
/// Dump the config to stdout
|
|
ShowConfig,
|
|
|
|
// Validate passed config file
|
|
ValidateConfig,
|
|
|
|
// Show which font face Ghostty loads a codepoint from.
|
|
ShowFace,
|
|
|
|
// List, (eventually) view, and (eventually) send crash reports.
|
|
CrashReport,
|
|
|
|
// Boo!
|
|
Boo,
|
|
|
|
// Use IPC to tell the running Ghostty to open a new window.
|
|
NewWindow,
|
|
}
|
|
|
|
impl Action {
|
|
/// All actions in declaration order.
|
|
pub const ALL: &'static [Action] = &[
|
|
Action::Version,
|
|
Action::Help,
|
|
Action::ListFonts,
|
|
Action::ListKeybinds,
|
|
Action::ListThemes,
|
|
Action::ListColors,
|
|
Action::ListActions,
|
|
Action::SshCache,
|
|
Action::EditConfig,
|
|
Action::ShowConfig,
|
|
Action::ValidateConfig,
|
|
Action::ShowFace,
|
|
Action::CrashReport,
|
|
Action::Boo,
|
|
Action::NewWindow,
|
|
];
|
|
|
|
/// The CLI name of the action (`@tagName`), e.g. `list-fonts`.
|
|
#[must_use]
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Action::Version => "version",
|
|
Action::Help => "help",
|
|
Action::ListFonts => "list-fonts",
|
|
Action::ListKeybinds => "list-keybinds",
|
|
Action::ListThemes => "list-themes",
|
|
Action::ListColors => "list-colors",
|
|
Action::ListActions => "list-actions",
|
|
Action::SshCache => "ssh-cache",
|
|
Action::EditConfig => "edit-config",
|
|
Action::ShowConfig => "show-config",
|
|
Action::ValidateConfig => "validate-config",
|
|
Action::ShowFace => "show-face",
|
|
Action::CrashReport => "crash-report",
|
|
Action::Boo => "boo",
|
|
Action::NewWindow => "new-window",
|
|
}
|
|
}
|
|
|
|
/// `std.meta.stringToEnum`.
|
|
#[must_use]
|
|
pub fn from_name(name: &str) -> Option<Action> {
|
|
Action::ALL.iter().copied().find(|a| a.name() == name)
|
|
}
|
|
|
|
/// Returns the filename associated with an action. This is a relative
|
|
/// path from the root src/ directory of the Zig sources.
|
|
#[must_use]
|
|
pub fn file(self) -> String {
|
|
format!("cli/{}.zig", self.name().replace('-', "_"))
|
|
}
|
|
|
|
/// The non-internal option fields of the action's `Options` struct with
|
|
/// their type classification (Zig: `Action.options()` + `@typeInfo`).
|
|
/// Used by the build-data generator for shell completions.
|
|
#[must_use]
|
|
pub fn option_fields(self) -> Vec<(&'static str, ghostty_config::cli_args::FieldKind)> {
|
|
use ghostty_config::{cli_args::FieldKind, macros::ConfigStruct};
|
|
fn of<T: ConfigStruct>() -> Vec<(&'static str, FieldKind)> {
|
|
T::FIELDS
|
|
.iter()
|
|
.filter(|f| !f.name.starts_with('_'))
|
|
.map(|f| (f.name, f.kind))
|
|
.collect()
|
|
}
|
|
match self {
|
|
Action::Version | Action::EditConfig | Action::CrashReport | Action::Boo => Vec::new(),
|
|
Action::Help => of::<crate::help::Options>(),
|
|
Action::ListFonts => of::<crate::list_fonts::Options>(),
|
|
Action::ListKeybinds => of::<crate::list_keybinds::Options>(),
|
|
Action::ListThemes => of::<crate::list_themes::Options>(),
|
|
Action::ListColors => of::<crate::list_colors::Options>(),
|
|
Action::ListActions => of::<crate::list_actions::Options>(),
|
|
Action::SshCache => of::<crate::ssh_cache::Options>(),
|
|
Action::ShowConfig => of::<crate::show_config::Options>(),
|
|
Action::ValidateConfig => of::<crate::validate_config::Options>(),
|
|
Action::ShowFace => of::<crate::show_face::Options>(),
|
|
// `class: ?[:0]const u8` (the other fields are internal).
|
|
Action::NewWindow => vec![("class", FieldKind::OptionalOther)],
|
|
}
|
|
}
|
|
|
|
/// The help text of the action (the doc comment of the Zig `run`
|
|
/// function, i.e. `help_strings.Action.<name>`), without trailing newline.
|
|
#[must_use]
|
|
pub fn help_text(self) -> &'static str {
|
|
// Generated by `build.rs` (via `ghostty-helpgen`) from the doc
|
|
// comment of each action's `run` function.
|
|
include!(concat!(env!("OUT_DIR"), "/action_help.rs"))
|
|
}
|
|
|
|
/// Run the action. `args` is the full argument vector (including
|
|
/// `argv[0]`). This returns the exit code to exit with.
|
|
///
|
|
/// If the action requests its help text (`--help`/`-h`), the help text
|
|
/// is printed to stdout and 0 is returned.
|
|
pub fn run(self, args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
|
match self.run_main(args) {
|
|
Err(Error::ActionHelpRequested) => {
|
|
// 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.
|
|
let text = format!("{}\n", self.help_text());
|
|
let mut stdout =
|
|
crate::zig_writer::ZigWriter::new(crate::zig_writer::stdout_sink(), 1024);
|
|
if let Err(e) = stdout.write_all(text.as_bytes()) {
|
|
log::warn!("failed to write help text: {e}\n");
|
|
return Ok(1);
|
|
}
|
|
if let Err(e) = stdout.flush() {
|
|
log::warn!("failed to flush help text: {e}\n");
|
|
return Ok(1);
|
|
}
|
|
Ok(0)
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
fn run_main(self, args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
|
match self {
|
|
Action::Version => crate::version::run(args),
|
|
Action::Help => crate::help::run(args),
|
|
Action::ListFonts => crate::list_fonts::run(args),
|
|
Action::ListKeybinds => crate::list_keybinds::run(args),
|
|
Action::ListThemes => crate::list_themes::run(args),
|
|
Action::ListColors => crate::list_colors::run(args),
|
|
Action::ListActions => crate::list_actions::run(args),
|
|
Action::SshCache => crate::ssh_cache::run(args),
|
|
Action::EditConfig => crate::edit_config::run(args),
|
|
Action::ShowConfig => crate::show_config::run(args),
|
|
Action::ValidateConfig => crate::validate_config::run(args),
|
|
Action::CrashReport => crate::crash_report::run(args),
|
|
Action::ShowFace => crate::show_face::run(args),
|
|
Action::Boo => crate::boo::run(args),
|
|
Action::NewWindow => crate::new_window::run(args),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ActionEnum for Action {
|
|
fn from_name(name: &str) -> Option<Self> {
|
|
Action::from_name(name)
|
|
}
|
|
|
|
fn detect_special_case(arg: &str) -> Option<SpecialCase<Self>> {
|
|
// If we see a "-e" and we haven't seen a command yet, then
|
|
// we are done looking for commands. This special case enables
|
|
// `ghostty -e ghostty +command`. If we've seen a command we
|
|
// still want to keep looking because
|
|
// `ghostty +command -e +command` is invalid.
|
|
if arg == "-e" {
|
|
return Some(SpecialCase::AbortIfNoAction);
|
|
}
|
|
|
|
// Special case, --version always outputs the version no
|
|
// matter what, no matter what other args exist.
|
|
if arg == "--version" {
|
|
return Some(SpecialCase::Action(Action::Version));
|
|
}
|
|
|
|
// --help matches "help" but if a subcommand is specified
|
|
// then we match the subcommand.
|
|
if arg == "--help" || arg == "-h" {
|
|
return Some(SpecialCase::Fallback(Action::Help));
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Action {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.name())
|
|
}
|
|
}
|
|
|
|
/// Detect the CLI action from `args` (full argv, `argv[0]` included).
|
|
pub fn detect(args: &[impl AsRef<[u8]>]) -> Result<Option<Action>, DetectError> {
|
|
detect_iter::<Action, _, _>(args.iter())
|
|
}
|
|
|
|
/// Convert raw OS arguments (Zig `[]const u8` argv) into the argument strings
|
|
/// used by the CLI layers. No bytes are lost: invalid UTF-8 bytes use the
|
|
/// reversible escape encoding of `ghostty_os::osbytes`, which the config
|
|
/// layer (`cli_args`) decodes back to the original bytes.
|
|
#[must_use]
|
|
pub fn os_args_to_bytes(args: &[std::ffi::OsString]) -> Vec<Vec<u8>> {
|
|
args.iter()
|
|
.map(|a| ghostty_os::osbytes::os_bytes(a))
|
|
.collect()
|
|
}
|
|
|
|
/// [`detect`] over raw OS arguments.
|
|
pub fn detect_os(args: &[std::ffi::OsString]) -> Result<Option<Action>, DetectError> {
|
|
detect(&os_args_to_bytes(args))
|
|
}
|
|
|
|
/// [`run_from_args`] over raw OS arguments (byte-preserving).
|
|
#[must_use]
|
|
pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> {
|
|
run_from_args(&os_args_to_bytes(args))
|
|
}
|
|
|
|
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
|
|
/// action from `args` (the full argv), print the errors Ghostty prints for
|
|
/// invalid invocations and run the action.
|
|
///
|
|
/// Returns `None` if no action was requested (the caller should launch the
|
|
/// terminal), or the exit code the process should terminate with.
|
|
#[must_use]
|
|
pub fn run_from_args(args: &[impl AsRef<[u8]>]) -> Option<u8> {
|
|
let action = match detect(args) {
|
|
Ok(a) => a?,
|
|
Err(err) => {
|
|
let mut stderr = std::io::stderr().lock();
|
|
let _ = match err {
|
|
DetectError::MultipleActions => stderr.write_all(
|
|
b"Error: multiple CLI actions specified. You must specify only one\n\
|
|
action starting with the `+` character.\n",
|
|
),
|
|
DetectError::InvalidAction => stderr.write_all(
|
|
b"Error: unknown CLI action specified. CLI actions are specified with\n\
|
|
the '+' character.\n\n\
|
|
All valid CLI actions can be listed with `ghostty +help`\n",
|
|
),
|
|
};
|
|
let _ = stderr.flush();
|
|
return Some(1);
|
|
}
|
|
};
|
|
|
|
log::info!("executing CLI action={action}");
|
|
Some(match action.run(args) {
|
|
Ok(code) => code,
|
|
Err(err) => {
|
|
log::error!("CLI action failed error={err}");
|
|
1
|
|
}
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used)]
|
|
use ghostty_config::cli_args::split_args;
|
|
|
|
use super::*;
|
|
|
|
fn detect_str(s: &str) -> Result<Option<Action>, DetectError> {
|
|
detect_iter::<Action, _, _>(split_args(s))
|
|
}
|
|
|
|
#[test]
|
|
fn parse_action_none() {
|
|
assert_eq!(None, detect_str("--a=42 --b --b-f=false").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_action_version() {
|
|
for s in [
|
|
"--a=42 --b --b-f=false --version",
|
|
"--version --a=42 --b --b-f=false",
|
|
"--c=84 --d --version --a=42 --b --b-f=false",
|
|
] {
|
|
assert_eq!(Some(Action::Version), detect_str(s).unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_action_plus() {
|
|
for s in [
|
|
"--a=42 --b --b-f=false +version",
|
|
"+version --a=42 --b --b-f=false",
|
|
"--c=84 --d +version --a=42 --b --b-f=false",
|
|
] {
|
|
assert_eq!(Some(Action::Version), detect_str(s).unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_action_plus_ignores_e() {
|
|
assert_eq!(None, detect_str("--a=42 -e +version").unwrap());
|
|
assert_eq!(
|
|
Err(DetectError::MultipleActions),
|
|
detect_str("+list-fonts --a=42 -e +version")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_fallback() {
|
|
assert_eq!(Some(Action::Help), detect_str("--help").unwrap());
|
|
assert_eq!(Some(Action::Help), detect_str("-h").unwrap());
|
|
assert_eq!(
|
|
Some(Action::ListThemes),
|
|
detect_str("+list-themes --help").unwrap()
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn os_args_keep_non_utf8_bytes_and_still_dispatch() {
|
|
use std::os::unix::ffi::OsStringExt;
|
|
let args = vec![
|
|
std::ffi::OsString::from("ghostty"),
|
|
std::ffi::OsString::from_vec(b"--title=\xff".to_vec()),
|
|
std::ffi::OsString::from("+version"),
|
|
];
|
|
assert_eq!(Some(Action::Version), detect_os(&args).unwrap());
|
|
let strings = os_args_to_bytes(&args);
|
|
assert_eq!(strings[1], b"--title=\xff");
|
|
}
|
|
|
|
#[test]
|
|
fn names_round_trip() {
|
|
for a in Action::ALL {
|
|
assert_eq!(Some(*a), Action::from_name(a.name()));
|
|
assert_ne!("", a.help_text());
|
|
assert!(a.file().starts_with("cli/"));
|
|
}
|
|
assert_eq!("cli/list_fonts.zig", Action::ListFonts.file());
|
|
}
|
|
}
|