fix(config): match Zig's numeric grammar, NUL handling and diagnostics

Resolves the 6 differential gap groups in docs/rewrite/notes/review-
rv9-codex.md (details in final-Z11.md):
- ghostty_os::zig_fmt ports std.fmt.parseInt/parseUnsigned/parseFloat
  (underscores, 0x/0o/0b prefixes, hex floats, inf/nan) with the Zig std
  tests and replaces every int/float parse in config, keybind
  parameters, ssh-cache and desktop detection.
- Interior NUL no longer panics in font names and ends C strings like
  Zig's backing bytes (fontconfig, C config getters, title actions,
  clipboard callback, relative paths, config-file, shaders, background
  image, GTK CSS).
- GTK new-window arguments are trimmed of ASCII whitespace only.
- Command tokenization uses a port of std.process.ArgIteratorGeneral
  (incl. CR/LF separators) with its std tests.
- UTF-8 validation happens where Zig's field parsers do it, so error
  kinds match (metric modifiers, quick-terminal size, link, ...).

A scripted differential run of 202 fields x ~220 hostile/valid values
against /usr/bin/ghostty (+validate-config and +show-config: exit code,
stdout, stderr) now has zero mismatches besides build-specific defaults
(theme resources path, auto-update-channel, gtk-opengl-debug); it also
surfaced and fixed background-blur failure state, NaN formatting and
NaN/-0 finalize clamps. Regression tests: ghostty-config/tests/
zig_parity.rs.

Test Plan:
- cargo test --workspace --no-fail-fast (3456 passed, exit 0)
- just check-macos (both), just check-windows, just check-wasm
This commit is contained in:
ddidderr committed 2026-10-12 12:35:37 +02:00
1 parent 7480f99d91
commit 79a7fd4f76
26 files changed
+1835 -228

No files matched your search

+22 -15
View File
@@ -177,10 +177,8 @@ impl SplitResizeParameter {
pub(super) fn parse_param(param: &str) -> Result<SplitResizeParameter, Error> {
let mut it = param.split(',');
let dir = SplitResizeDirection::parse_param(it.next().ok_or(Error::InvalidFormat)?)?;
let amt = it
.next()
.ok_or(Error::InvalidFormat)?
.parse::<u16>()
let amt = it.next().ok_or(Error::InvalidFormat)?;
let amt = ghostty_os::zig_fmt::parse_int::<u16>(amt.as_bytes(), 10)
.map_err(|_| Error::InvalidFormat)?;
// If we have extra parameters it is an error
if it.next().is_some() {
@@ -304,16 +302,23 @@ pub(super) trait ParseNum: Sized {
fn parse_param(s: &str) -> Result<Self, Error>;
}
macro_rules! num_param {
macro_rules! int_param {
($($t:ty),*) => {$(
impl ParseNum for $t {
fn parse_param(s: &str) -> Result<$t, Error> {
s.parse::<$t>().map_err(|_| Error::InvalidFormat)
ghostty_os::zig_fmt::parse_int::<$t>(s.as_bytes(), 10)
.map_err(|_| Error::InvalidFormat)
}
}
)*};
}
num_param!(f32, usize, isize, i16);
int_param!(usize, isize, i16);
impl ParseNum for f32 {
fn parse_param(s: &str) -> Result<f32, Error> {
ghostty_os::zig_fmt::parse_float::<f32>(s.as_bytes()).map_err(|_| Error::InvalidFormat)
}
}
/// Escape in the style of Zig's `std.zig.stringEscape`.
pub(super) fn string_escape(s: &[u8], out: &mut String) {
@@ -378,12 +383,14 @@ impl Action {
"set_tab_title" => Ok(Self::SetTabTitle(bytes()?)),
"activate_key_table" => Ok(Self::ActivateKeyTable(bytes()?)),
"activate_key_table_once" => Ok(Self::ActivateKeyTableOnce(bytes()?)),
// Zig never validates UTF-8 for enum/numeric parameters: the
// bytes just fail the field's own grammar (`InvalidFormat`) for
// a known action and `InvalidAction` for an unknown name. U+FFFD
// is accepted by none of those grammars, so it stands in for any
// invalid parameter.
_ => Self::parse_named(
name,
param
.map(std::str::from_utf8)
.transpose()
.map_err(|_| Error::InvalidAction)?,
param.map(|p| std::str::from_utf8(p).unwrap_or("\u{fffd}")),
),
}
}
@@ -1930,15 +1937,15 @@ impl Action {
}
Action::IncreaseFontSize(v) => {
out.push(':');
out.push_str(&v.to_string());
out.push_str(&ghostty_os::zig_fmt::format_float_d(*v));
}
Action::DecreaseFontSize(v) => {
out.push(':');
out.push_str(&v.to_string());
out.push_str(&ghostty_os::zig_fmt::format_float_d(*v));
}
Action::SetFontSize(v) => {
out.push(':');
out.push_str(&v.to_string());
out.push_str(&ghostty_os::zig_fmt::format_float_d(*v));
}
Action::Search(v) => {
out.push(':');
@@ -1954,7 +1961,7 @@ impl Action {
}
Action::ScrollPageFractional(v) => {
out.push(':');
out.push_str(&v.to_string());
out.push_str(&ghostty_os::zig_fmt::format_float_d(*v));
}
Action::ScrollPageLines(v) => {
out.push(':');
+20 -12
View File
@@ -173,11 +173,7 @@ impl<'a> Parser<'a> {
// This is kind of hacky but we put a dummy trigger
// for chained inputs. The `next` will never yield this
// because we have chain set.
input: if chain {
"a"
} else {
std::str::from_utf8(&input[..eql_idx]).map_err(|_| Error::InvalidFormat)?
},
input: if chain { b"a" } else { &input[..eql_idx] },
i: 0,
},
action: Action::parse_bytes(&input[eql_idx + 1..])?,
@@ -279,13 +275,16 @@ pub struct SequenceIterator<'a> {
/// The input of triggers. This is expected to be ONLY triggers. Things
/// like the "unconsumed:" prefix or action must be stripped before
/// passing to this iterator.
pub input: &'a str,
pub input: &'a [u8],
pub i: usize,
}
impl<'a> SequenceIterator<'a> {
pub fn new(input: &'a str) -> Self {
SequenceIterator { input, i: 0 }
SequenceIterator {
input: input.as_bytes(),
i: 0,
}
}
/// Returns the next trigger in the sequence if there is no parsing error.
@@ -294,9 +293,9 @@ impl<'a> SequenceIterator<'a> {
return Ok(None);
}
let rem = &self.input[self.i..];
let idx = rem.find('>').unwrap_or(rem.len());
let idx = rem.iter().position(|b| *b == b'>').unwrap_or(rem.len());
self.i += idx + 1;
Ok(Some(Trigger::parse(&rem[..idx])?))
Ok(Some(Trigger::parse_bytes(&rem[..idx])?))
}
/// Returns true if there are no more triggers to parse.
@@ -545,19 +544,28 @@ impl Trigger {
/// not be part of a sequence (i.e. `a>b`). This parses exactly a single
/// trigger.
pub fn parse(input: &str) -> Result<Trigger, Error> {
Trigger::parse_bytes(input.as_bytes())
}
/// [`Trigger::parse`] over the original bytes. Invalid UTF-8 can only
/// reach (and fail) the key lookups, which all end in `InvalidFormat`.
pub fn parse_bytes(input: &[u8]) -> Result<Trigger, Error> {
if input.is_empty() {
return Err(Error::InvalidFormat);
}
let mut result = Trigger::default();
let mut rem: &str = input;
let mut rem: &[u8] = input;
'outer: while !rem.is_empty() {
let idx = rem.find('+').unwrap_or(rem.len());
let idx = rem.iter().position(|b| *b == b'+').unwrap_or(rem.len());
let part = &rem[..idx];
rem = if idx >= rem.len() {
""
b""
} else {
&rem[idx + 1..]
};
let Ok(part) = std::str::from_utf8(part) else {
return Err(Error::InvalidFormat);
};
// Check if its a modifier
{