Files
ghostty-rs/crates/ghostty-cli/src/ghostty.rs
T
ddidderr 8519f1ba1b fix: preserve non-UTF-8 bytes in OS, config and CLI layers
Zig treats paths, arguments and environment values as byte slices; the
port converted them lossily in many places. Now (final-Z1.md):
- argv, env (incl. present non-UTF-8 values), HOME, PATH lookup,
  passwd, resources dir, TMPDIR, cgroup, config path canonicalization,
  +validate-config, +list-themes and Zig-string escapes (\xFF) keep raw
  bytes. String-based config values carry bytes via ghostty_os::osbytes,
  a bijective escape (noncharacter U+FDD0 + byte) that leaves all valid
  UTF-8 - including Nerd Font private-use icons - unchanged.
- os/file rlimit fallbacks exist on all targets.
- i18n locale canonicalization is an exact port of libintl's tables
  (e.g. pa-Guru -> pa_IN).
- New ghostty-build-info crate is the single source of version, channel,
  commit and build mode for all consumers.

Test Plan:
- cargo test -p ghostty-os -p ghostty-config -p ghostty-cli
  (incl. randomized osbytes round-trip property tests)
2026-10-12 08:14:02 +02:00

390 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: &[String]) -> 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 = std::io::stdout().lock();
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: &[String]) -> 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: &[String]) -> 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_strings(args: &[std::ffi::OsString]) -> Vec<String> {
args.iter()
.map(|a| ghostty_os::osbytes::os_to_config_string(a))
.collect()
}
/// [`detect`] over raw OS arguments.
pub fn detect_os(args: &[std::ffi::OsString]) -> Result<Option<Action>, DetectError> {
detect(&os_args_to_strings(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_strings(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: &[String]) -> 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_strings(&args);
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&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());
}
}