feat(cli): port ghostty +action CLI commands

Ports ZIG/src/cli into ghostty-cli: action detection/dispatch with
per-action --help, and the actions help, version, list-actions,
list-colors, list-keybinds, list-themes (plain listing plus an
interactive ratatui browser mirroring Ghostty's libvaxis UI),
show-config, validate-config, edit-config, crash-report (listing),
ssh-cache and boo (235 embedded frames).

Verified byte-for-byte against the installed Ghostty 1.3.1 for stdout,
stderr and exit codes of most actions, incl. tty output in a pty at
widths 20-200. Known differences: +list-colors --plain prints all 782
colors (Ghostty truncates at 617 because it never flushes), +version
has placeholder build info until build_info::set is wired up.

Deferred: list-fonts, show-face (need font discovery integration) and
new-window (needs GTK IPC); they return NotYetAvailable for now.

Test Plan:
- cargo test -p ghostty-cli (86 tests)
- cargo run -p ghostty-cli --example cli -- +list-keybinds --plain
  and diff against `ghostty +list-keybinds --plain`
This commit is contained in:
ddidderr committed 2026-10-11 22:22:01 +02:00
1 parent 5462bd8e3e
commit f2c4b40062
46 files changed
+7135 -6

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
//! Detection of `+action` arguments (ZIG/src/cli/action.zig).
use std::fmt;
/// Errors from [`detect_iter`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetectError {
/// Multiple actions were detected. You can specify at most one
/// action on the CLI otherwise the behavior desired is ambiguous.
MultipleActions,
/// An unknown action was specified.
InvalidAction,
}
impl fmt::Display for DetectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DetectError::MultipleActions => f.write_str("MultipleActions"),
DetectError::InvalidAction => f.write_str("InvalidAction"),
}
}
}
impl std::error::Error for DetectError {}
/// The action enum E can implement `detect_special_case` to return this
/// enum in order to perform various special case actions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpecialCase<E> {
/// Immediately return this action.
Action(E),
/// Return this action if no other action is found.
Fallback(E),
/// If there is no pending action (we haven't seen an action yet)
/// then we should return no action. This is kind of weird but is
/// a special case to allow "-e" in Ghostty.
AbortIfNoAction,
}
/// An enum of actions that can be detected from CLI args.
pub trait ActionEnum: Sized + Copy {
/// `std.meta.stringToEnum`.
fn from_name(name: &str) -> Option<Self>;
/// `E.detectSpecialCase`; the default has no special cases.
fn detect_special_case(_arg: &str) -> Option<SpecialCase<Self>> {
None
}
}
/// Detect the action from the process CLI args (Zig: `detectArgs`).
pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
detect_iter(std::env::args())
}
/// Detect the action from any iterator. Each iterator value should yield
/// a CLI argument such as "--foo".
pub fn detect_iter<E: ActionEnum, I, S>(iter: I) -> Result<Option<E>, DetectError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut fallback: Option<E> = None;
let mut pending: Option<E> = None;
for arg in iter {
let arg = arg.as_ref();
// Allow handling of special cases.
if let Some(special) = E::detect_special_case(arg) {
match special {
SpecialCase::Action(a) => return Ok(Some(a)),
SpecialCase::Fallback(a) => fallback = Some(a),
SpecialCase::AbortIfNoAction => {
if pending.is_none() {
return Ok(None);
}
}
}
}
// Commands must start with "+"
let Some(name) = arg.strip_prefix('+') else {
continue;
};
if pending.is_some() {
return Err(DetectError::MultipleActions);
}
pending = Some(E::from_name(name).ok_or(DetectError::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.is_some() {
return Ok(pending);
}
// If we have no action but we have a fallback, then we return that.
Ok(fallback)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use ghostty_config::cli_args::split_args;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Plain {
Foo,
Bar,
Baz,
}
impl ActionEnum for Plain {
fn from_name(name: &str) -> Option<Self> {
match name {
"foo" => Some(Plain::Foo),
"bar" => Some(Plain::Bar),
"baz" => Some(Plain::Baz),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Special {
Foo,
Bar,
}
/// Which special case `Special` reports for which argument.
static SPECIAL_ARG: std::sync::Mutex<(&str, u8)> = std::sync::Mutex::new(("", 0));
impl ActionEnum for Special {
fn from_name(name: &str) -> Option<Self> {
match name {
"foo" => Some(Special::Foo),
"bar" => Some(Special::Bar),
_ => None,
}
}
fn detect_special_case(arg: &str) -> Option<SpecialCase<Self>> {
let (special, kind) = *SPECIAL_ARG.lock().unwrap();
if arg != special {
return None;
}
Some(match kind {
0 => SpecialCase::Action(Special::Foo),
1 => SpecialCase::Fallback(Special::Foo),
_ => SpecialCase::AbortIfNoAction,
})
}
}
/// The special-case tests share a global, so they run in one test body
/// each under this lock.
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn set_special(arg: &'static str, kind: u8) {
*SPECIAL_ARG.lock().unwrap() = (arg, kind);
}
fn detect<E: ActionEnum>(s: &str) -> Result<Option<E>, DetectError> {
detect_iter::<E, _, _>(split_args(s))
}
#[test]
fn detect_direct_match() {
assert_eq!(Some(Plain::Foo), detect::<Plain>("+foo").unwrap());
}
#[test]
fn detect_invalid_match() {
assert_eq!(Err(DetectError::InvalidAction), detect::<Plain>("+invalid"));
}
#[test]
fn detect_multiple_actions() {
assert_eq!(
Err(DetectError::MultipleActions),
detect::<Plain>("+foo +bar")
);
}
#[test]
fn detect_no_match() {
assert_eq!(None, detect::<Plain>("--some-flag").unwrap());
let _ = Plain::Baz;
}
#[test]
fn detect_special_case_action() {
let _g = LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
set_special("--special", 0);
assert_eq!(
Some(Special::Foo),
detect::<Special>("--special +bar").unwrap()
);
assert_eq!(
Some(Special::Foo),
detect::<Special>("+bar --special").unwrap()
);
assert_eq!(Some(Special::Bar), detect::<Special>("+bar").unwrap());
}
#[test]
fn detect_special_case_fallback() {
let _g = LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
set_special("--special", 1);
assert_eq!(Some(Special::Foo), detect::<Special>("--special").unwrap());
assert_eq!(
Some(Special::Bar),
detect::<Special>("+bar --special").unwrap()
);
assert_eq!(
Some(Special::Bar),
detect::<Special>("--special +bar").unwrap()
);
}
#[test]
fn detect_special_case_abort_if_no_action() {
let _g = LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
set_special("-e", 2);
assert_eq!(None, detect::<Special>("-e").unwrap());
assert_eq!(Some(Special::Foo), detect::<Special>("+foo -e").unwrap());
assert_eq!(None, detect::<Special>("-e +bar").unwrap());
}
}