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 61e7a680ac
commit a57ff41d34
26 files changed
+1835 -228

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
//! Port of Zig 0.16 `std.process.ArgIteratorGeneral` (`process/Args.zig`).
//!
//! Splits a command line on space, tab, CR and LF. Double quotes group;
//! backslashes are only special before a double quote (or a single quote
//! when `single_quotes` is enabled). A NUL byte terminates the command line,
//! exactly like Zig's sentinel handling.
/// `ArgIteratorGeneral` options.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Options {
/// Treat `#` at a token start as a comment up to end of line.
pub comments: bool,
/// Treat `'` as a quote character.
pub single_quotes: bool,
}
/// Iterator over the arguments of a command line.
#[derive(Debug, Clone)]
pub struct ArgIteratorGeneral<'a> {
cmd_line: &'a [u8],
index: usize,
options: Options,
}
impl<'a> ArgIteratorGeneral<'a> {
#[must_use]
pub fn new(options: Options, cmd_line: &'a [u8]) -> Self {
ArgIteratorGeneral {
cmd_line,
index: 0,
options,
}
}
/// The byte at the cursor, or 0 at the end (the sentinel).
fn character(&self) -> u8 {
self.cmd_line.get(self.index).copied().unwrap_or(0)
}
/// Skips whitespace (and comments). Returns false at the terminator.
fn skip_whitespace(&mut self) -> bool {
loop {
match self.character() {
0 => return false,
b' ' | b'\t' | b'\r' | b'\n' => {}
b'#' => {
if self.options.comments {
loop {
match self.cmd_line.get(self.index).copied().unwrap_or(0) {
b'\n' => break,
0 => return false,
_ => self.index += 1,
}
}
// The loop `continue`s, which advances past the newline.
} else {
break;
}
}
_ => break,
}
self.index += 1;
}
true
}
/// Skips the next argument. Returns false at the end.
pub fn skip_arg(&mut self) -> bool {
if !self.skip_whitespace() {
return false;
}
let mut backslash_count: usize = 0;
let mut in_quote = false;
loop {
let c = self.character();
match c {
0 => return true,
b'"' | b'\'' => {
if !self.options.single_quotes && c == b'\'' {
backslash_count = 0;
} else {
let quote_is_real = backslash_count.is_multiple_of(2);
if quote_is_real {
in_quote = !in_quote;
}
}
}
b'\\' => backslash_count += 1,
b' ' | b'\t' | b'\r' | b'\n' => {
if !in_quote {
return true;
}
backslash_count = 0;
}
_ => backslash_count = 0,
}
self.index += 1;
}
}
/// The next argument, or `None` at the end.
pub fn next_arg(&mut self) -> Option<Vec<u8>> {
if !self.skip_whitespace() {
return None;
}
let mut out: Vec<u8> = Vec::new();
let mut backslash_count: usize = 0;
let mut in_quote = false;
let emit_backslashes =
|out: &mut Vec<u8>, n: usize| out.extend(std::iter::repeat_n(b'\\', n));
loop {
let c = self.character();
match c {
0 => {
emit_backslashes(&mut out, backslash_count);
return Some(out);
}
b'"' | b'\'' => {
if !self.options.single_quotes && c == b'\'' {
emit_backslashes(&mut out, backslash_count);
backslash_count = 0;
out.push(c);
} else {
let quote_is_real = backslash_count.is_multiple_of(2);
emit_backslashes(&mut out, backslash_count / 2);
backslash_count = 0;
if quote_is_real {
in_quote = !in_quote;
} else {
out.push(b'"');
}
}
}
b'\\' => backslash_count += 1,
b' ' | b'\t' | b'\r' | b'\n' => {
emit_backslashes(&mut out, backslash_count);
backslash_count = 0;
if in_quote {
out.push(c);
} else {
return Some(out);
}
}
_ => {
emit_backslashes(&mut out, backslash_count);
backslash_count = 0;
out.push(c);
}
}
self.index += 1;
}
}
}
impl Iterator for ArgIteratorGeneral<'_> {
type Item = Vec<u8>;
fn next(&mut self) -> Option<Vec<u8>> {
self.next_arg()
}
}
#[cfg(test)]
#[allow(clippy::byte_char_slices)]
mod tests {
use super::*;
fn response_file(input: &str, expected: &[&str]) {
let it = ArgIteratorGeneral::new(
Options {
comments: true,
single_quotes: true,
},
input.as_bytes(),
);
let got: Vec<Vec<u8>> = it.collect();
let want: Vec<Vec<u8>> = expected.iter().map(|s| s.as_bytes().to_vec()).collect();
assert_eq!(got, want, "{input:?}");
}
#[test]
fn response_file_arg_parsing() {
response_file("a b\nc d\\", &["a", "b", "c", "d\\"]);
response_file("a b c d\\", &["a", "b", "c", "d\\"]);
response_file(
"j\n k l # this is a comment \\\\ \\\\\\ \\\\\\\\ \"none\" \"\\\\\" \"\\\\\\\"\n \"m\" #another comment\n",
&["j", "k", "l", "m"],
);
response_file(
" \"\" q \"\"\n \"r s # t\" \"u\\\" v\" #another comment\n",
&["", "q", "", "r s # t", "u\" v"],
);
response_file(
" -l\"advapi32\" a# b#c d#\ne\\\\\\",
&["-ladvapi32", "a#", "b#c", "d#", "e\\\\\\"],
);
response_file(" 'foo' \"bar\"", &["foo", "bar"]);
}
fn general(input: &[u8]) -> Vec<Vec<u8>> {
ArgIteratorGeneral::new(Options::default(), input).collect()
}
#[test]
fn all_zig_separators_split() {
for sep in [b' ', b'\t', b'\r', b'\n'] {
assert_eq!(
general(&[b'a', sep, b'b']),
vec![b"a".to_vec(), b"b".to_vec()]
);
assert_eq!(general(&[sep, sep, b'a', sep, sep]), vec![b"a".to_vec()]);
}
// VT and FF are not separators.
assert_eq!(general(b"a\x0bb\x0cc"), vec![b"a\x0bb\x0cc".to_vec()]);
// Quoted separators are kept.
assert_eq!(
general(b"\"a\nb\r\tc\" d"),
vec![b"a\nb\r\tc".to_vec(), b"d".to_vec()]
);
}
#[test]
fn quoting_and_backslash_rules() {
assert_eq!(general(b"a\\b"), vec![b"a\\b".to_vec()]);
assert_eq!(general(b"\\\\\\\"x"), vec![b"\\\"x".to_vec()]);
assert_eq!(general(b"\\\\\"x y\""), vec![b"\\x y".to_vec()]);
// Single quotes are ordinary characters by default.
assert_eq!(general(b"'a b'"), vec![b"'a".to_vec(), b"b'".to_vec()]);
// Unterminated quote runs to the end.
assert_eq!(general(b"\"a b"), vec![b"a b".to_vec()]);
// Trailing backslashes are emitted verbatim.
assert_eq!(general(b"a\\\\"), vec![b"a\\\\".to_vec()]);
// Empty quoted argument.
assert_eq!(general(b"\"\" x"), vec![b"".to_vec(), b"x".to_vec()]);
}
#[test]
fn nul_terminates_the_command_line() {
assert_eq!(general(b"a b\0c d"), vec![b"a".to_vec(), b"b".to_vec()]);
assert_eq!(general(b"\0a"), Vec::<Vec<u8>>::new());
assert_eq!(general(b"\"a\0b\" c"), vec![b"a".to_vec()]);
}
#[test]
fn skip_matches_next() {
let mut it = ArgIteratorGeneral::new(Options::default(), b"a \"b c\"\nd");
assert!(it.skip_arg());
assert_eq!(it.next_arg(), Some(b"b c".to_vec()));
assert!(it.skip_arg());
assert!(!it.skip_arg());
}
}