feat(config): port the configuration system and CLI argument parser

Ports ZIG/src/config (minus C/WASM APIs) and cli/{args,CommaSplitter,
diagnostics}.zig into ghostty-config:
- all 202 options with exact names, types, defaults and verbatim docs,
  declared through one config_struct! macro; Zig's comptime reflection is
  replaced by the ConfigField/CliTarget traits
- loading: default XDG files, -e, config-file includes (optional '?',
  cycle detection), themes incl. light:/dark: pairs, conditional config,
  replay, finalize() derived defaults, clone/changed diffing for reload
- Linux/GTK default keybinds built on ghostty_input::binding::Set
- formatter producing +show-config output

Verified against the installed Ghostty 1.3.1: `+show-config --default`
and `--default --docs` match byte for byte (except gtk-opengl-debug,
which differs between debug/release builds), as do two complex loaded
configs.

Open items (docs/rewrite/notes/ghostty-config.md): url regex needs an
Oniguruma-compatible engine (onig) in the renderer; some font/terminal
types are local copies awaiting integration; KeyRemapSet equality is
order sensitive unlike Zig.

Test Plan:
- cargo test -p ghostty-config (255 tests)
- compare `ghostty +show-config --default --docs` with our formatter
This commit is contained in:
ddidderr committed 2026-10-11 22:00:12 +02:00
1 parent 800fe5be5e
commit 33d15a2dc2
34 files changed
+14894 -1

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
//! A command to execute (port of `config/command.zig`).
use crate::cli_args::{ArgError, ConfigField};
use crate::formatter::EntryFormatter;
use std::fmt;
/// A command to execute (argv0 and args).
///
/// A command is specified as a simple string such as "nvim a b c".
/// By default, we expect the downstream to do some sort of shell expansion
/// on this string.
///
/// If a command is already expanded and the user does NOT want to do
/// shell expansion (because this usually requires a round trip into
/// /bin/sh or equivalent), specify a `direct:`-prefix. e.g.
/// `direct:nvim a b c`.
///
/// The whitespace before or around the prefix is ignored. For example,
/// ` direct:nvim a b c` and `direct: nvim a b c` are equivalent.
///
/// If the command is not absolute, it'll be looked up via the PATH.
/// For the shell-expansion case, we let the shell do this. For the
/// direct case, we do this directly.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// Execute a command directly, e.g. via `exec`. The format here
/// is already structured to be ready to passed directly to `exec`
/// with index zero being the command to execute.
///
/// Index zero is not guaranteed to be an absolute path, and may require
/// PATH lookup. It is up to the downstream to do this, usually via
/// delegation to something like `execvp`.
Direct(Vec<String>),
/// Execute a command via shell expansion. This provides the command
/// as a single string that is expected to be expanded in some way
/// (up to the downstream). Usually `/bin/sh -c`.
Shell(String),
}
impl Default for Command {
fn default() -> Self {
Command::Shell(String::new())
}
}
/// Iterates over each argument in the command.
#[derive(Debug, Clone)]
pub struct ArgIterator {
args: std::vec::IntoIter<String>,
}
impl Iterator for ArgIterator {
type Item = String;
fn next(&mut self) -> Option<String> {
self.args.next()
}
}
/// Best-effort shell string splitter matching Zig's
/// `std.process.ArgIteratorGeneral(.{})` (whitespace separated, double
/// quotes group, backslashes only special before a double quote).
fn split_general(s: &str) -> Vec<String> {
let b = s.as_bytes();
let mut out = vec![];
let mut i = 0;
loop {
// skip whitespace
while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
if i >= b.len() {
break;
}
let mut cur: Vec<u8> = vec![];
let mut quoted = false;
while i < b.len() {
match b[i] {
b' ' | b'\t' if !quoted => break,
b'\\' => {
let mut n = 0;
while i < b.len() && b[i] == b'\\' {
n += 1;
i += 1;
}
if i < b.len() && b[i] == b'"' {
cur.extend(std::iter::repeat_n(b'\\', n / 2));
if n % 2 == 1 {
cur.push(b'"');
i += 1;
}
} else {
cur.extend(std::iter::repeat_n(b'\\', n));
}
}
b'"' => {
quoted = !quoted;
i += 1;
}
c => {
cur.push(c);
i += 1;
}
}
}
out.push(String::from_utf8_lossy(&cur).into_owned());
}
out
}
impl Command {
/// Creates a command as a single string, joining arguments as
/// necessary with spaces. Its not guaranteed that this is a valid
/// command; it is only meant to be human readable.
#[must_use]
pub fn string(&self) -> String {
match self {
Command::Shell(v) => v.clone(),
Command::Direct(v) => v.join(" "),
}
}
/// Get an iterator over the arguments array.
///
/// For direct commands this just iterates over the array. For shell
/// commands this uses a best effort shell string parser (like Zig's
/// `ArgIteratorGeneral`). This is not guaranteed to be 100% accurate,
/// but it works for common cases.
#[must_use]
pub fn arg_iterator(&self) -> ArgIterator {
let args = match self {
Command::Direct(v) => v.clone(),
Command::Shell(v) => split_general(v),
};
ArgIterator {
args: args.into_iter(),
}
}
}
impl ConfigField for Command {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
// Input is required. Whitespace on the edges isn't needed.
// Commands must be non-empty.
let input = input.ok_or(ArgError::ValueRequired)?;
let trimmed = input.trim_matches(' ');
if trimmed.is_empty() {
return Err(ArgError::ValueRequired);
}
// If we have a `:` then we MIGHT have a prefix to specify what
// tag we should use.
let (direct, str) = match trimmed.find(':') {
Some(idx) if &trimmed[..idx] == "direct" => (true, &trimmed[idx + 1..]),
Some(idx) if &trimmed[..idx] == "shell" => (false, &trimmed[idx + 1..]),
_ => (false, trimmed),
};
let str = str.trim_matches(' ');
*self = if direct {
// We're not shell expanding, so the arguments are naively
// split on spaces.
Command::Direct(str.split(' ').map(str::to_string).collect())
} else {
Command::Shell(str.to_string())
};
Ok(())
}
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
match self {
Command::Shell(v) => writeln!(f.writer, "{} = {v}", f.name),
Command::Direct(v) => writeln!(f.writer, "{} = direct:{}", f.name, v.join(" ")),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::formatter::format_entry;
#[test]
fn command_parse_cli_errors() {
let mut v = Command::default();
assert_eq!(v.parse_cli(None), Err(ArgError::ValueRequired));
assert_eq!(v.parse_cli(Some("")), Err(ArgError::ValueRequired));
assert_eq!(v.parse_cli(Some(" ")), Err(ArgError::ValueRequired));
}
#[test]
fn command_parse_cli_shell_expanded() {
let mut v = Command::default();
v.parse_cli(Some("echo hello")).unwrap();
assert_eq!(v, Command::Shell("echo hello".into()));
// Spaces are stripped
v.parse_cli(Some(" echo hello ")).unwrap();
assert_eq!(v, Command::Shell("echo hello".into()));
}
#[test]
fn command_parse_cli_direct() {
let mut v = Command::default();
v.parse_cli(Some("direct:echo hello")).unwrap();
assert_eq!(v, Command::Direct(vec!["echo".into(), "hello".into()]));
// Spaces around the prefix
v.parse_cli(Some(" direct: echo hello")).unwrap();
assert_eq!(v, Command::Direct(vec!["echo".into(), "hello".into()]));
}
#[test]
fn command_arg_iterator_shell() {
let v = Command::Shell("echo hello world".into());
let mut it = v.arg_iterator();
assert_eq!(it.next().unwrap(), "echo");
assert_eq!(it.next().unwrap(), "hello");
assert_eq!(it.next().unwrap(), "world");
assert!(it.next().is_none());
}
#[test]
fn command_arg_iterator_shell_quotes() {
let v = Command::Shell("echo \"hello world\" a\\\"b".into());
let args: Vec<String> = v.arg_iterator().collect();
assert_eq!(args, ["echo", "hello world", "a\"b"]);
}
#[test]
fn command_arg_iterator_direct() {
let v = Command::Direct(vec!["echo".into(), "hello world".into()]);
let mut it = v.arg_iterator();
assert_eq!(it.next().unwrap(), "echo");
assert_eq!(it.next().unwrap(), "hello world");
assert!(it.next().is_none());
}
#[test]
fn command_string_shell() {
let v = Command::Shell("echo hello world".into());
assert_eq!(v.string(), "echo hello world");
}
#[test]
fn command_string_direct() {
let v = Command::Direct(vec!["echo".into(), "hello world".into()]);
assert_eq!(v.string(), "echo hello world");
}
#[test]
fn command_format_config_shell() {
let mut v = Command::default();
v.parse_cli(Some("echo hello")).unwrap();
let mut s = String::new();
format_entry("a", &v, &mut s).unwrap();
assert_eq!(s, "a = echo hello\n");
}
#[test]
fn command_format_config_direct() {
let mut v = Command::default();
v.parse_cli(Some("direct: echo hello")).unwrap();
let mut s = String::new();
format_entry("a", &v, &mut s).unwrap();
assert_eq!(s, "a = direct:echo hello\n");
}
}