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:
1 parent
ecd67c8f3e
commit
16eb1fba61
22 files changed
+290
-186
No files matched your search
@@ -463,29 +463,9 @@ pub struct Rgb {
|
||||
/// Alias matching the Zig name.
|
||||
pub type RGB = Rgb;
|
||||
|
||||
/// Zig `std.fmt.parseUnsigned(u16, value, 16)` semantics (optional leading
|
||||
/// `+`; a leading `-` is only valid if the value is zero; no underscores).
|
||||
/// Zig `std.fmt.parseUnsigned(u16, value, 16)`.
|
||||
fn parse_unsigned_hex_u16(value: &[u8]) -> Option<u16> {
|
||||
let (neg, digits) = match value.first()? {
|
||||
b'+' => (false, &value[1..]),
|
||||
b'-' => (true, &value[1..]),
|
||||
_ => (false, value),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for &c in digits {
|
||||
let d = (c as char).to_digit(16)?;
|
||||
acc = acc * 16 + d;
|
||||
if acc > u16::MAX as u32 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if neg && acc != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(acc as u16)
|
||||
ghostty_zigfmt::parse_unsigned::<u16>(value, 16).ok()
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
@@ -537,8 +517,7 @@ impl Rgb {
|
||||
///
|
||||
/// The value should be between 0.0 and 1.0, inclusive.
|
||||
fn from_intensity(value: &[u8]) -> Result<u8, InvalidFormat> {
|
||||
let s = std::str::from_utf8(value).map_err(|_| InvalidFormat)?;
|
||||
let i: f64 = s.parse().map_err(|_| InvalidFormat)?;
|
||||
let i: f64 = ghostty_zigfmt::parse_float::<f64>(value).map_err(|_| InvalidFormat)?;
|
||||
if !(0.0..=1.0).contains(&i) && !i.is_nan() {
|
||||
return Err(InvalidFormat);
|
||||
}
|
||||
@@ -854,6 +833,30 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
// Zig `std.fmt.parseUnsigned` / `parseFloat` differences from Rust's.
|
||||
#[test]
|
||||
fn zig_parse_hex_u16_underscore_and_sign() {
|
||||
assert_eq!(Some(0x1f), parse_unsigned_hex_u16(b"1_f"));
|
||||
assert_eq!(Some(0x1f), parse_unsigned_hex_u16(b"1__f"));
|
||||
assert_eq!(None, parse_unsigned_hex_u16(b"+f"));
|
||||
assert_eq!(None, parse_unsigned_hex_u16(b"-0"));
|
||||
assert_eq!(None, parse_unsigned_hex_u16(b"_1"));
|
||||
assert_eq!(None, parse_unsigned_hex_u16(b"f_"));
|
||||
assert_eq!(None, parse_unsigned_hex_u16(b"0x1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zig_parse_float_intensity() {
|
||||
// hex floats and `_` separators are valid Zig floats.
|
||||
assert_eq!(Ok(127), Rgb::from_intensity(b"0x1p-1"));
|
||||
assert_eq!(Ok(127), Rgb::from_intensity(b"0.5_0"));
|
||||
assert!(Rgb::from_intensity(b"0_.5").is_err());
|
||||
assert_eq!(Ok(127), Rgb::from_intensity(b"+.5"));
|
||||
assert_eq!(Ok(255), Rgb::from_intensity(b"1."));
|
||||
assert!(Rgb::from_intensity(b"1.5").is_err());
|
||||
assert!(Rgb::from_intensity(b"").is_err());
|
||||
}
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
|
||||
Rgb { r, g, b }
|
||||
}
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -141,30 +141,10 @@ fn tokenize(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
buf.split(|&c| c == b';').filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Zig `std.fmt.parseInt(u9, s, 10)`: optional sign, digits only.
|
||||
/// Zig `std.fmt.parseInt(u9, s, 10)`.
|
||||
fn parse_u9(s: &[u8]) -> Option<u16> {
|
||||
let (neg, digits) = match s.first()? {
|
||||
b'+' => (false, &s[1..]),
|
||||
b'-' => (true, &s[1..]),
|
||||
_ => (false, s),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for &c in digits {
|
||||
if !c.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
acc = acc * 10 + (c - b'0') as u32;
|
||||
if acc > 511 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if neg && acc != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(acc as u16)
|
||||
let v = ghostty_zigfmt::parse_int::<i32>(s, 10).ok()?;
|
||||
u16::try_from(v).ok().filter(|v| *v <= 511)
|
||||
}
|
||||
|
||||
/// Parse any color operation string. This should NOT include the operation
|
||||
@@ -542,6 +522,21 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zig_parse_u9_differences() {
|
||||
assert_eq!(Some(10), parse_u9(b"1_0"));
|
||||
assert_eq!(Some(500), parse_u9(b"5_0_0"));
|
||||
assert_eq!(Some(5), parse_u9(b"+5"));
|
||||
assert_eq!(Some(0), parse_u9(b"-0"));
|
||||
assert_eq!(None, parse_u9(b"-1"));
|
||||
assert_eq!(None, parse_u9(b"0x10"));
|
||||
assert_eq!(None, parse_u9(b"1_"));
|
||||
assert_eq!(None, parse_u9(b"_1"));
|
||||
assert_eq!(Some(511), parse_u9(b"511"));
|
||||
assert_eq!(None, parse_u9(b"512"));
|
||||
assert_eq!(None, parse_u9(b"99999999999"));
|
||||
}
|
||||
|
||||
const RED: Rgb = Rgb { r: 255, g: 0, b: 0 };
|
||||
const BLUE: Rgb = Rgb { r: 0, g: 0, b: 255 };
|
||||
|
||||
|
||||
@@ -216,9 +216,8 @@ impl Field {
|
||||
if value.iter().any(|c| !c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(value)
|
||||
ghostty_zigfmt::parse_int::<u64>(value, 10)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(FieldValue::U64)
|
||||
}
|
||||
// String fields
|
||||
|
||||
@@ -67,28 +67,8 @@ enum UpdateError {
|
||||
|
||||
/// Equivalent of Zig `std.fmt.parseInt(u4, value, 10)`.
|
||||
fn parse_u4(value: &[u8]) -> Option<u8> {
|
||||
let (neg, digits) = match value.first() {
|
||||
Some(b'+') => (false, &value[1..]),
|
||||
Some(b'-') => (true, &value[1..]),
|
||||
_ => (false, value),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for &c in digits {
|
||||
if !c.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
acc = acc * 10 + (c - b'0') as u32;
|
||||
if acc > 15 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if neg && acc != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(acc as u8)
|
||||
let v = ghostty_zigfmt::parse_int::<i32>(value, 10).ok()?;
|
||||
u8::try_from(v).ok().filter(|v| *v <= 15)
|
||||
}
|
||||
|
||||
impl OSC<'_> {
|
||||
@@ -213,6 +193,20 @@ pub fn parse<S: ByteStorage>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zig_parse_u4_differences() {
|
||||
assert_eq!(Some(10), parse_u4(b"1_0"));
|
||||
assert_eq!(Some(5), parse_u4(b"+5"));
|
||||
assert_eq!(Some(0), parse_u4(b"-0"));
|
||||
assert_eq!(Some(15), parse_u4(b"15"));
|
||||
assert_eq!(None, parse_u4(b"16"));
|
||||
assert_eq!(None, parse_u4(b"5_0_0"));
|
||||
assert_eq!(None, parse_u4(b"-1"));
|
||||
assert_eq!(None, parse_u4(b"0x1"));
|
||||
assert_eq!(None, parse_u4(b"1_"));
|
||||
assert_eq!(None, parse_u4(b"99999999999"));
|
||||
}
|
||||
|
||||
fn run(input: &[u8]) -> Option<Command<'static>> {
|
||||
let mut p = Parser::init(false);
|
||||
for &ch in input {
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::osc::{
|
||||
};
|
||||
|
||||
/// Parse a base-10 unsigned integer from raw bytes (Zig `parseUnsigned`).
|
||||
fn parse_unsigned<T: std::str::FromStr>(bytes: &[u8]) -> Option<T> {
|
||||
std::str::from_utf8(bytes).ok()?.parse().ok()
|
||||
fn parse_unsigned<T: ghostty_zigfmt::ZigInt>(bytes: &[u8]) -> Option<T> {
|
||||
ghostty_zigfmt::parse_unsigned::<T>(bytes, 10).ok()
|
||||
}
|
||||
|
||||
/// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension.
|
||||
@@ -221,6 +221,18 @@ fn conemu_inner(data: &[u8]) -> Option<Command<'_>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zig_parse_unsigned_differences() {
|
||||
assert_eq!(run("9;1;4_2"), Command::ConemuSleep { duration_ms: 42 });
|
||||
assert_eq!(run("9;1;+42"), Command::ConemuSleep { duration_ms: 100 });
|
||||
assert_eq!(run("9;1;-0"), Command::ConemuSleep { duration_ms: 100 });
|
||||
assert_eq!(run("9;1;0x10"), Command::ConemuSleep { duration_ms: 100 });
|
||||
assert_eq!(run("9;1;70000"), Command::ConemuSleep { duration_ms: 100 });
|
||||
assert_eq!(parse_unsigned::<usize>(b"5_0"), Some(50));
|
||||
assert_eq!(parse_unsigned::<usize>(b"+5"), None);
|
||||
assert_eq!(parse_unsigned::<usize>(b"5_"), None);
|
||||
}
|
||||
|
||||
fn run(input: &str) -> Command<'static> {
|
||||
let mut p = Parser::init(false);
|
||||
for ch in input.bytes() {
|
||||
|
||||
@@ -223,9 +223,8 @@ impl OptionKey {
|
||||
// If we're looking for exit_code we special case it.
|
||||
// as the first value.
|
||||
if self == OptionKey::ExitCode {
|
||||
return std::str::from_utf8(full)
|
||||
return ghostty_zigfmt::parse_int::<i32>(full, 10)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.map(OptionValue::Int);
|
||||
}
|
||||
|
||||
@@ -428,6 +427,16 @@ pub fn parse<S: ByteStorage>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zig_parse_int_exit_code() {
|
||||
assert_eq!(run("133;D;1_0").unwrap().exit_code(), Some(10));
|
||||
assert_eq!(run("133;D;+5").unwrap().exit_code(), Some(5));
|
||||
assert_eq!(run("133;D;-5").unwrap().exit_code(), Some(-5));
|
||||
assert_eq!(run("133;D;0x10").unwrap().exit_code(), None);
|
||||
assert_eq!(run("133;D;1_").unwrap().exit_code(), None);
|
||||
assert_eq!(run("133;D;99999999999").unwrap().exit_code(), None);
|
||||
}
|
||||
|
||||
fn run(input: &str) -> Option<Command<'static>> {
|
||||
let mut p = Parser::init(false);
|
||||
for ch in input.bytes() {
|
||||
|
||||
Reference in new issue
Block a user