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 47b479dcfb
commit 67367dcaf5
34 files changed
+14894 -1

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
//! String-literal escape parsing (port of `config/string.zig`).
/// Errors from string parsing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StringError {
InvalidString,
}
impl std::fmt::Display for StringError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("InvalidString")
}
}
impl std::error::Error for StringError {}
impl From<StringError> for crate::cli_args::ArgError {
fn from(e: StringError) -> Self {
crate::cli_args::ArgError::Other(e.to_string())
}
}
/// Parse a Zig escape sequence starting at `bytes[*i]` (which must be a
/// backslash). On success advances `*i` past the sequence and returns the
/// codepoint.
fn parse_escape_sequence(bytes: &[u8], i: &mut usize) -> Option<u32> {
let mut idx = *i;
debug_assert_eq!(bytes[idx], b'\\');
idx += 1;
let c = *bytes.get(idx)?;
idx += 1;
let cp = match c {
b'n' => u32::from(b'\n'),
b'r' => u32::from(b'\r'),
b'\\' => u32::from(b'\\'),
b't' => u32::from(b'\t'),
b'\'' => u32::from(b'\''),
b'"' => u32::from(b'"'),
b'x' => {
let h = bytes.get(idx..idx + 2)?;
let h = std::str::from_utf8(h).ok()?;
if !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
idx += 2;
u32::from_str_radix(h, 16).ok()?
}
b'u' => {
if *bytes.get(idx)? != b'{' {
return None;
}
idx += 1;
let start = idx;
let mut v: u32 = 0;
loop {
let b = *bytes.get(idx)?;
idx += 1;
if b == b'}' {
if idx - 1 == start {
return None;
}
break;
}
let d = (b as char).to_digit(16)?;
v = v.checked_mul(16)?.checked_add(d)?;
if v > 0x10ffff {
return None;
}
}
v
}
_ => return None,
};
*i = idx;
Some(cp)
}
/// Parse a string literal into a string. The string can contain
/// any valid Zig string literal escape sequences.
pub fn parse(bytes: &str) -> Result<String, StringError> {
let b = bytes.as_bytes();
let mut out = String::with_capacity(bytes.len());
let mut i = 0;
while i < b.len() {
if b[i] != b'\\' {
// Copy a whole UTF-8 character.
let ch = bytes[i..]
.chars()
.next()
.ok_or(StringError::InvalidString)?;
out.push(ch);
i += ch.len_utf8();
continue;
}
let cp = parse_escape_sequence(b, &mut i).ok_or(StringError::InvalidString)?;
// Surrogates cannot be encoded.
out.push(char::from_u32(cp).ok_or(StringError::InvalidString)?);
}
Ok(out)
}
/// Creates an iterator that requires no allocation to extract codepoints
/// from the string literal, parsing escape sequences as it goes.
#[must_use]
pub fn codepoint_iterator(bytes: &str) -> CodepointIterator<'_> {
CodepointIterator { bytes, i: 0 }
}
pub struct CodepointIterator<'a> {
bytes: &'a str,
i: usize,
}
impl CodepointIterator<'_> {
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Result<Option<u32>, StringError> {
if self.i >= self.bytes.len() {
return Ok(None);
}
if self.bytes.as_bytes()[self.i] == b'\\' {
// An escape sequence
return parse_escape_sequence(self.bytes.as_bytes(), &mut self.i)
.map(Some)
.ok_or(StringError::InvalidString);
}
// Not an escape, parse as UTF-8
let ch = self.bytes[self.i..]
.chars()
.next()
.ok_or(StringError::InvalidString)?;
self.i += ch.len_utf8();
Ok(Some(u32::from(ch)))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parse_empty() {
assert_eq!(parse("").unwrap(), "");
}
#[test]
fn parse_no_escapes() {
assert_eq!(parse("hello world").unwrap(), "hello world");
}
#[test]
fn parse_escapes() {
assert_eq!(parse("hello\\nworld").unwrap(), "hello\nworld");
assert_eq!(
parse("hello\\u{1F601}world").unwrap(),
"hello\u{1F601}world"
);
}
#[test]
fn parse_invalid() {
assert_eq!(parse("\\q"), Err(StringError::InvalidString));
assert_eq!(parse("\\"), Err(StringError::InvalidString));
assert_eq!(parse("\\u{}"), Err(StringError::InvalidString));
assert_eq!(parse("\\u{110000}"), Err(StringError::InvalidString));
assert_eq!(parse("\\x4"), Err(StringError::InvalidString));
}
#[test]
fn codepoint_iterator_empty() {
let mut it = codepoint_iterator("");
assert_eq!(it.next().unwrap(), None);
}
#[test]
fn codepoint_iterator_ascii_no_escapes() {
let mut it = codepoint_iterator("abc");
assert_eq!(it.next().unwrap(), Some('a' as u32));
assert_eq!(it.next().unwrap(), Some('b' as u32));
assert_eq!(it.next().unwrap(), Some('c' as u32));
assert_eq!(it.next().unwrap(), None);
}
#[test]
fn codepoint_iterator_multibyte_utf8() {
// │ is U+2502 (3 bytes in UTF-8)
let mut it = codepoint_iterator("a│b");
assert_eq!(it.next().unwrap(), Some('a' as u32));
assert_eq!(it.next().unwrap(), Some('│' as u32));
assert_eq!(it.next().unwrap(), Some('b' as u32));
assert_eq!(it.next().unwrap(), None);
}
#[test]
fn codepoint_iterator_escape_sequences() {
let mut it = codepoint_iterator("a\\tb\\n\\\\");
assert_eq!(it.next().unwrap(), Some('a' as u32));
assert_eq!(it.next().unwrap(), Some('\t' as u32));
assert_eq!(it.next().unwrap(), Some('b' as u32));
assert_eq!(it.next().unwrap(), Some('\n' as u32));
assert_eq!(it.next().unwrap(), Some('\\' as u32));
assert_eq!(it.next().unwrap(), None);
}
#[test]
fn codepoint_iterator_unicode_escape() {
let mut it = codepoint_iterator("\\u{2502}x");
assert_eq!(it.next().unwrap(), Some('│' as u32));
assert_eq!(it.next().unwrap(), Some('x' as u32));
assert_eq!(it.next().unwrap(), None);
}
#[test]
fn codepoint_iterator_emoji_unicode_escape() {
let mut it = codepoint_iterator("\\u{1F601}");
assert_eq!(it.next().unwrap(), Some(0x1F601));
assert_eq!(it.next().unwrap(), None);
}
}