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

+354
View File
@@ -0,0 +1,354 @@
//! Iterator to split a string into fields by commas, taking into account
//! quotes and escapes.
//!
//! Supports the same escapes as in Zig literal strings.
//!
//! Quotes must begin and end with a double quote (`"`). It is an error to not
//! end a quote that was begun. To include a double quote inside a quote (or to
//! not have a double quote start a quoted section) escape it with a backslash.
//!
//! Single quotes (`'`) are not special, they do not begin a quoted block.
//!
//! Zig multiline string literals are NOT supported.
//!
//! Quotes and escapes are not stripped or decoded, that must be handled as a
//! separate step!
/// Errors the splitter can produce.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
UnclosedQuote,
UnfinishedEscape,
IllegalEscape,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Error::UnclosedQuote => "UnclosedQuote",
Error::UnfinishedEscape => "UnfinishedEscape",
Error::IllegalEscape => "IllegalEscape",
};
f.write_str(s)
}
}
impl std::error::Error for Error {}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
Normal,
Quoted,
Escape,
HexEscape,
UnicodeEscape,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum UnicodeState {
Start,
Digits,
}
/// Splits a string on commas (see module docs).
#[derive(Debug, Clone)]
pub struct CommaSplitter<'a> {
/// the string that we are splitting
str: &'a str,
/// how much of the string has been consumed so far
index: usize,
}
impl<'a> CommaSplitter<'a> {
/// initialize a splitter with the given string
#[must_use]
pub fn new(str: &'a str) -> Self {
Self { str, index: 0 }
}
/// return the next field, `Ok(None)` if no more fields
pub fn next_field(&mut self) -> Result<Option<&'a str>, Error> {
let bytes = self.str.as_bytes();
if self.index >= bytes.len() {
return Ok(None);
}
let start = self.index;
let mut state = State::Normal;
// state to return to when done processing an escape sequence
let mut last = State::Normal;
let mut hexescape_digits = 0usize;
let mut unicodeescape_state = UnicodeState::Start;
let mut unicodeescape_digits = 0usize;
let mut unicodeescape_value = 0usize;
loop {
match state {
State::Normal => {
if self.index >= bytes.len() {
return Ok(Some(&self.str[start..]));
}
match bytes[self.index] {
b',' => {
self.index += 1;
return Ok(Some(&self.str[start..self.index - 1]));
}
b'"' => {
self.index += 1;
state = State::Quoted;
}
b'\\' => {
self.index += 1;
last = State::Normal;
state = State::Escape;
}
_ => self.index += 1,
}
}
State::Quoted => {
if self.index >= bytes.len() {
return Err(Error::UnclosedQuote);
}
match bytes[self.index] {
b'"' => {
self.index += 1;
state = State::Normal;
}
b'\\' => {
self.index += 1;
last = State::Quoted;
state = State::Escape;
}
_ => self.index += 1,
}
}
State::Escape => {
if self.index >= bytes.len() {
return Err(Error::UnfinishedEscape);
}
match bytes[self.index] {
b'n' | b'r' | b't' | b'\\' | b'\'' | b'"' => {
self.index += 1;
state = last;
}
b'x' => {
self.index += 1;
hexescape_digits = 0;
state = State::HexEscape;
}
b'u' => {
self.index += 1;
unicodeescape_state = UnicodeState::Start;
unicodeescape_digits = 0;
unicodeescape_value = 0;
state = State::UnicodeEscape;
}
_ => return Err(Error::IllegalEscape),
}
}
State::HexEscape => {
if self.index >= bytes.len() {
return Err(Error::UnfinishedEscape);
}
if bytes[self.index].is_ascii_hexdigit() {
self.index += 1;
hexescape_digits += 1;
if hexescape_digits == 2 {
state = last;
}
} else {
return Err(Error::IllegalEscape);
}
}
State::UnicodeEscape => {
if self.index >= bytes.len() {
return Err(Error::UnfinishedEscape);
}
match unicodeescape_state {
UnicodeState::Start => {
if bytes[self.index] == b'{' {
self.index += 1;
unicodeescape_value = 0;
unicodeescape_state = UnicodeState::Digits;
} else {
return Err(Error::IllegalEscape);
}
}
UnicodeState::Digits => {
let c = bytes[self.index];
// Note: the Zig implementation does not add 10 for
// the a-f digits; this is preserved.
let d = match c {
b'}' => {
self.index += 1;
if unicodeescape_digits == 0 {
return Err(Error::IllegalEscape);
}
state = last;
continue;
}
b'0'..=b'9' => c - b'0',
b'a'..=b'f' => c - b'a',
b'A'..=b'F' => c - b'A',
_ => return Err(Error::IllegalEscape),
};
self.index += 1;
unicodeescape_digits += 1;
unicodeescape_value <<= 4;
unicodeescape_value += usize::from(d);
if unicodeescape_value > 0x10ffff {
return Err(Error::IllegalEscape);
}
}
}
}
}
}
}
/// Return any remaining string data, whether it has a comma or not.
pub fn rest(&mut self) -> Option<&'a str> {
if self.index >= self.str.len() {
return None;
}
let r = &self.str[self.index..];
self.index = self.str.len();
Some(r)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn all(s: &str) -> Result<Vec<&str>, Error> {
let mut sp = CommaSplitter::new(s);
let mut v = vec![];
while let Some(x) = sp.next_field()? {
v.push(x);
}
Ok(v)
}
fn first_err(s: &str) -> Error {
CommaSplitter::new(s).next_field().unwrap_err()
}
#[test]
fn splitter_1() {
assert_eq!(all("a,b,c").unwrap(), ["a", "b", "c"]);
}
#[test]
fn splitter_2() {
assert_eq!(all("").unwrap(), Vec::<&str>::new());
}
#[test]
fn splitter_3() {
assert_eq!(all("a").unwrap(), ["a"]);
}
#[test]
fn splitter_4() {
assert_eq!(all("\\x5a").unwrap(), ["\\x5a"]);
}
#[test]
fn splitter_5() {
assert_eq!(all("'a',b").unwrap(), ["'a'", "b"]);
}
#[test]
fn splitter_6() {
assert_eq!(all("'a,b',c").unwrap(), ["'a", "b'", "c"]);
}
#[test]
fn splitter_7() {
assert_eq!(all("\"a,b\",c").unwrap(), ["\"a,b\"", "c"]);
}
#[test]
fn splitter_8() {
assert_eq!(all(" a , b ").unwrap(), [" a ", " b "]);
}
#[test]
fn splitter_9() {
assert_eq!(first_err("\\x"), Error::UnfinishedEscape);
}
#[test]
fn splitter_10() {
assert_eq!(first_err("\\x5"), Error::UnfinishedEscape);
}
#[test]
fn splitter_11() {
assert_eq!(first_err("\\u"), Error::UnfinishedEscape);
}
#[test]
fn splitter_12() {
assert_eq!(first_err("\\u{"), Error::UnfinishedEscape);
}
#[test]
fn splitter_13() {
assert_eq!(first_err("\\u{}"), Error::IllegalEscape);
}
#[test]
fn splitter_14() {
assert_eq!(first_err("\\u{h1}"), Error::IllegalEscape);
}
#[test]
fn splitter_15() {
assert_eq!(all("\\u{10ffff}").unwrap(), ["\\u{10ffff}"]);
}
#[test]
fn splitter_16() {
assert_eq!(first_err("\\u{110000}"), Error::IllegalEscape);
}
#[test]
fn splitter_17() {
assert_eq!(first_err("\\d"), Error::IllegalEscape);
}
#[test]
fn splitter_18() {
assert_eq!(
all("\\n\\r\\t\\\"\\'\\\\").unwrap(),
["\\n\\r\\t\\\"\\'\\\\"]
);
}
#[test]
fn splitter_19() {
assert_eq!(all("\"abc'def'ghi\"").unwrap(), ["\"abc'def'ghi\""]);
}
#[test]
fn splitter_20() {
assert_eq!(all("\",\",abc").unwrap(), ["\",\"", "abc"]);
}
#[test]
fn splitter_21() {
assert_eq!(all("'a','b', 'c'").unwrap(), ["'a'", "'b'", " 'c'"]);
}
#[test]
fn splitter_22() {
assert_eq!(first_err("abc\"def"), Error::UnclosedQuote);
}
#[test]
fn splitter_23() {
assert_eq!(
all("title:\"Focus Split: Up\",description:\"Focus the split above, if it exists.\",action:goto_split:up").unwrap(),
[
"title:\"Focus Split: Up\"",
"description:\"Focus the split above, if it exists.\"",
"action:goto_split:up"
]
);
}
#[test]
fn splitter_24() {
let mut s = CommaSplitter::new("a,b,c,def");
assert_eq!(s.next_field().unwrap(), Some("a"));
assert_eq!(s.next_field().unwrap(), Some("b"));
assert_eq!(s.rest(), Some("c,def"));
assert_eq!(s.next_field().unwrap(), None);
}
#[test]
fn splitter_25() {
let mut s = CommaSplitter::new("a,\\u{10,df}");
assert_eq!(s.next_field().unwrap(), Some("a"));
assert_eq!(s.next_field(), Err(Error::IllegalEscape));
}
}