fix: use Zig's numeric parsing in protocol and escape-sequence parsers

Rust's str::parse accepts and rejects different inputs than Zig's
std.fmt.parseInt/parseFloat (underscores, base prefixes, signs). The
config layer already used the Zig ports; now the protocol parsers do
too (call site -> Zig reference -> status in final-Z12.md):
- zig_fmt moves into the leaf crate ghostty-zigfmt (ghostty-vt cannot
  depend on ghostty-os); ghostty-os re-exports it unchanged.
- ghostty-vt: kitty graphics keys, kitty text sizing, OSC 4/5/10-19
  color indices, hex color components and intensities, OSC 9 sleep and
  progress, OSC 133 exit codes, OSC 3008 fields, kitty color palette.
- ghostty-terminal: tmux layout, format variables and pane tabs.
- ghostty-os: URI ports and %XX decoding; ghostty-bench CLI arguments.
Expected values were cross-checked against the real Zig toolchain.

Test Plan:
- cargo test --workspace --no-fail-fast
- just check-macos (both), just check-windows, just check-wasm
This commit is contained in:
ddidderr committed 2026-10-12 12:45:55 +02:00
1 parent ecd67c8f3e
commit 16eb1fba61
22 files changed
+290 -186

No files matched your search

+14 -10
View File
@@ -166,16 +166,10 @@ impl Kind {
if let Some(s) = Special::from_name(key) {
return Some(Kind::Special(s));
}
// Zig parseUnsigned(u8, key, 10): optional '+', digits only.
let digits = match key.first()? {
b'+' => &key[1..],
_ => key,
};
if digits.is_empty() || !digits.iter().all(u8::is_ascii_digit) {
return None;
}
let s = std::str::from_utf8(digits).ok()?;
s.parse::<u8>().ok().map(Kind::Palette)
// Zig parseUnsigned(u8, key, 10).
ghostty_zigfmt::parse_unsigned::<u8>(key, 10)
.ok()
.map(Kind::Palette)
}
}
@@ -233,6 +227,16 @@ mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn zig_parse_unsigned_palette_key() {
assert!(matches!(Kind::parse(b"1_0"), Some(Kind::Palette(10))));
assert!(Kind::parse(b"+5").is_none());
assert!(Kind::parse(b"-0").is_none());
assert!(Kind::parse(b"0x10").is_none());
assert!(Kind::parse(b"256").is_none());
assert!(Kind::parse(b"1_").is_none());
}
#[test]
fn request_records_round_trip() {
let mut reqs = vec![];
+24 -34
View File
@@ -24,45 +24,20 @@ pub enum Error {
Overflow,
}
/// Mirrors `std.fmt.parseInt(T, buf, 10)` for 32-bit targets: optional sign,
/// at least one digit, range checked.
/// Mirrors `std.fmt.parseInt(u32|i32, buf, 10)` (Zig `kv` value parsing),
/// including `_` digit separators and Zig's sign rules.
fn parse_int(buf: &[u8], signed: bool) -> Result<u32, Error> {
// kv_temp is at most 11 bytes so an i64 always suffices unless there
// are overflow digits, which we detect via checked arithmetic.
let (neg, digits) = match buf.first() {
Some(b'+') if buf.len() > 1 => (false, &buf[1..]),
Some(b'-') if buf.len() > 1 => (true, &buf[1..]),
_ => (false, buf),
use ghostty_zigfmt::ParseIntError;
let map = |e| match e {
ParseIntError::Overflow => Error::Overflow,
ParseIntError::InvalidCharacter => Error::InvalidCharacter,
};
if digits.is_empty() {
return Err(Error::InvalidCharacter);
}
let mut v: i64 = 0;
let mut overflow = false;
for &c in digits {
if !c.is_ascii_digit() {
return Err(Error::InvalidCharacter);
}
match v
.checked_mul(10)
.and_then(|x| x.checked_add(i64::from(c - b'0')))
{
Some(x) => v = x,
None => overflow = true,
}
}
if overflow {
return Err(Error::Overflow);
}
if neg {
v = -v;
}
if signed {
i32::try_from(v)
ghostty_zigfmt::parse_int::<i32>(buf, 10)
.map(|x| x as u32)
.map_err(|_| Error::Overflow)
.map_err(map)
} else {
u32::try_from(v).map_err(|_| Error::Overflow)
ghostty_zigfmt::parse_int::<u32>(buf, 10).map_err(map)
}
}
@@ -1158,6 +1133,21 @@ mod tests {
use super::*;
#[test]
fn zig_parse_int_values() {
// `_` separators, explicit `+`, negative zero for unsigned.
let c = parse("a=p,i=1_0,I=+7,r=-0,z=-1_0").unwrap();
let Control::Display(d) = c.control else {
panic!("expected display")
};
assert_eq!((d.image_id, d.image_number, d.rows, d.z), (10, 7, 0, -10));
// Signed keys accept negatives, unsigned do not.
assert!(matches!(parse("a=p,i=-1"), Err(Error::Overflow)));
assert!(matches!(parse("a=p,i=0x10"), Err(Error::InvalidCharacter)));
assert!(matches!(parse("a=p,i=1_"), Err(Error::InvalidCharacter)));
assert!(matches!(parse("a=p,i=4294967296"), Err(Error::Overflow)));
}
fn parse(input: &str) -> Result<Command, Error> {
let mut p = Parser::new();
for c in input.bytes() {