diff --git a/crates/ghostty-apprt-gtk/src/class/application.rs b/crates/ghostty-apprt-gtk/src/class/application.rs index 752a9b9..96c2cf7 100644 --- a/crates/ghostty-apprt-gtk/src/class/application.rs +++ b/crates/ghostty-apprt-gtk/src/class/application.rs @@ -1138,12 +1138,13 @@ impl Application { } if let Some(v) = s.strip_prefix("--working-directory=") { // D-Bus strings are plain UTF-8, including literal U+FDD0. - working_directory = - Some(ghostty_os::osbytes::EncodedOs::from_plain_text(v.trim())); + working_directory = Some(ghostty_os::osbytes::EncodedOs::from_plain_text( + trim_ascii_whitespace(v), + )); continue; } if let Some(v) = s.strip_prefix("--title=") { - title = Some(v.trim().to_owned()); + title = Some(trim_ascii_whitespace(v).to_owned()); } } } @@ -1869,3 +1870,30 @@ impl Drop for ReadyGuard { fn scopeguard_ready() -> ReadyGuard { ReadyGuard } + +/// `std.mem.trim(u8, v, &std.ascii.whitespace)`: only ASCII whitespace +/// (space, `\t`, `\n`, VT, FF, `\r`) is trimmed, never Unicode whitespace +/// such as U+00A0. +fn trim_ascii_whitespace(v: &str) -> &str { + v.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')) +} + +#[cfg(test)] +mod trim_tests { + use super::trim_ascii_whitespace; + + #[test] + fn new_window_arguments_trim_only_ascii_whitespace() { + assert_eq!( + trim_ascii_whitespace(" \t\n\r\x0b\x0c/tmp/a b \n"), + "/tmp/a b" + ); + assert_eq!(trim_ascii_whitespace("/tmp/name\u{a0}"), "/tmp/name\u{a0}"); + assert_eq!( + trim_ascii_whitespace("\u{a0}title\u{2003} "), + "\u{a0}title\u{2003}" + ); + assert_eq!(trim_ascii_whitespace("\x0btitle\x0b"), "title"); + assert_eq!(trim_ascii_whitespace(" \t "), ""); + } +} diff --git a/crates/ghostty-apprt-gtk/src/css.rs b/crates/ghostty-apprt-gtk/src/css.rs index 0c2c21e..c692a68 100644 --- a/crates/ghostty-apprt-gtk/src/css.rs +++ b/crates/ghostty-apprt-gtk/src/css.rs @@ -110,8 +110,8 @@ impl CssProviders { for p in &config.gtk_custom_css.value { let (path, optional) = match p { - Path::Optional(path) => (path.to_path(), true), - Path::Required(path) => (path.to_path(), false), + Path::Optional(path) => (path.to_syscall_path(), true), + Path::Required(path) => (path.to_syscall_path(), false), }; let path = match path { Ok(path) => path, diff --git a/crates/ghostty-cli/src/ssh_cache/entry.rs b/crates/ghostty-cli/src/ssh_cache/entry.rs index b23c3e2..c566ac4 100644 --- a/crates/ghostty-cli/src/ssh_cache/entry.rs +++ b/crates/ghostty-cli/src/ssh_cache/entry.rs @@ -40,7 +40,7 @@ impl Entry { let hostname = iter.next()?; let timestamp_str = iter.next()?; let terminfo_version = iter.next().unwrap_or("xterm-ghostty"); - let timestamp = match timestamp_str.parse::() { + let timestamp = match ghostty_os::zig_fmt::parse_int::(timestamp_str.as_bytes(), 10) { Ok(t) => t, Err(err) => { log::warn!("Invalid timestamp in cache entry: {timestamp_str} err={err}"); diff --git a/crates/ghostty-config/src/cli_args.rs b/crates/ghostty-config/src/cli_args.rs index b49c740..ecb4412 100644 --- a/crates/ghostty-config/src/cli_args.rs +++ b/crates/ghostty-config/src/cli_args.rs @@ -506,6 +506,42 @@ pub fn parse_packed_struct_with( Ok(()) } +/// Zig's `@max` on floats: a NaN operand yields the other operand and +/// `max(+0, -0)` is `+0`. +#[must_use] +pub fn zig_max(a: f64, b: f64) -> f64 { + if a.is_nan() { + b + } else if b.is_nan() { + a + } else if a == b { + if a.is_sign_negative() { b } else { a } + } else { + a.max(b) + } +} + +/// Zig's `@min` on floats: a NaN operand yields the other operand and +/// `min(+0, -0)` is `-0`. +#[must_use] +pub fn zig_min(a: f64, b: f64) -> f64 { + if a.is_nan() { + b + } else if b.is_nan() { + a + } else if a == b { + if a.is_sign_negative() { a } else { b } + } else { + a.min(b) + } +} + +/// `std.math.clamp` on floats (`@max(lower, @min(val, upper))`). +#[must_use] +pub fn zig_clamp(val: f64, lower: f64, upper: f64) -> f64 { + zig_max(lower, zig_min(val, upper)) +} + pub fn parse_bool(v: &str) -> Result { match v { "1" | "t" | "T" | "true" => Ok(true), @@ -514,42 +550,6 @@ pub fn parse_bool(v: &str) -> Result { } } -/// Parse an integer like Zig's `std.fmt.parseInt(T, s, 0)`: optional sign, -/// optional `0x`/`0o`/`0b` prefix, underscores between digits allowed. -pub fn parse_int_base0(s: &str) -> Option { - let (neg, rest) = match s.as_bytes().first()? { - b'-' => (true, &s[1..]), - b'+' => (false, &s[1..]), - _ => (false, s), - }; - let (base, digits) = if rest.len() >= 2 && rest.as_bytes()[0] == b'0' { - match rest.as_bytes()[1] { - b'x' => (16, &rest[2..]), - b'o' => (8, &rest[2..]), - b'b' => (2, &rest[2..]), - _ => (10, rest), - } - } else { - (10, rest) - }; - if digits.is_empty() { - return None; - } - let db = digits.as_bytes(); - if db[0] == b'_' || db[db.len() - 1] == b'_' || digits.contains("__") { - return None; - } - let mut acc: i128 = 0; - for &c in db { - if c == b'_' { - continue; - } - let d = i128::from((c as char).to_digit(base)?); - acc = acc.checked_mul(i128::from(base))?.checked_add(d)?; - } - Some(if neg { -acc } else { acc }) -} - // --------------------------------------------------------------------- // ConfigField impls for primitives @@ -580,10 +580,9 @@ macro_rules! impl_int_field { } fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { - let value = crate::cli_args::text_value(value)?; let v = value.ok_or(ArgError::ValueRequired)?; - let n = parse_int_base0(v).ok_or(ArgError::InvalidValue)?; - *self = <$t>::try_from(n).map_err(|_| ArgError::InvalidValue)?; + *self = ghostty_os::zig_fmt::parse_int::<$t>(v, 0) + .map_err(|_| ArgError::InvalidValue)?; Ok(()) } @@ -615,14 +614,14 @@ macro_rules! impl_float_field { } fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { - let value = crate::cli_args::text_value(value)?; let v = value.ok_or(ArgError::ValueRequired)?; - *self = v.parse::<$t>().map_err(|_| ArgError::InvalidValue)?; + *self = ghostty_os::zig_fmt::parse_float::<$t>(v) + .map_err(|_| ArgError::InvalidValue)?; Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { - writeln!(f.writer, "{} = {}", f.name, self) + writeln!(f.writer, "{} = {}", f.name, ghostty_os::zig_fmt::format_float_d(*self)) } } )*}; @@ -1642,12 +1641,45 @@ mod tests { } #[test] - fn parse_int_base0_forms() { - assert_eq!(parse_int_base0("0x_1"), None); - assert_eq!(parse_int_base0("1_000"), Some(1000)); - assert_eq!(parse_int_base0("-0b11"), Some(-3)); - assert_eq!(parse_int_base0("0o17"), Some(15)); - assert_eq!(parse_int_base0(""), None); + fn numeric_fields_use_zig_parse_int_and_parse_float_grammars() { + let mut n = 0u32; + n.parse_bytes(Some(b"0X10")).unwrap(); + assert_eq!(n, 16); + n.parse_bytes(Some(b"1__0")).unwrap(); + assert_eq!(n, 10); + n.parse_bytes(Some(b"+0b1_1")).unwrap(); + assert_eq!(n, 3); + for bad in ["0x_1", "_1", "1_", "-1", "0x", "", " 1", "1 ", "\u{e9}"] { + assert_eq!( + n.parse_bytes(Some(bad.as_bytes())), + Err(ArgError::InvalidValue), + "{bad:?}" + ); + } + assert_eq!(n.parse_bytes(None), Err(ArgError::ValueRequired)); + let mut i = 0i16; + i.parse_bytes(Some(b"-0O17")).unwrap(); + assert_eq!(i, -15); + assert_eq!(i.parse_bytes(Some(b"40000")), Err(ArgError::InvalidValue)); + // Invalid UTF-8 is just an invalid character for numbers. + assert_eq!(n.parse_bytes(Some(b"\xff")), Err(ArgError::InvalidValue)); + + let mut f = 0f32; + f.parse_bytes(Some(b"1_4")).unwrap(); + assert_eq!(f, 14.0); + f.parse_bytes(Some(b"0x1.cp+3")).unwrap(); + assert_eq!(f, 14.0); + f.parse_bytes(Some(b"-INF")).unwrap(); + assert_eq!(f, f32::NEG_INFINITY); + f.parse_bytes(Some(b"nan")).unwrap(); + assert!(f.is_nan()); + for bad in ["1__4", "infx", ".", "1e", " 1", "0x"] { + assert_eq!( + f.parse_bytes(Some(bad.as_bytes())), + Err(ArgError::InvalidValue), + "{bad:?}" + ); + } } #[test] diff --git a/crates/ghostty-config/src/command.rs b/crates/ghostty-config/src/command.rs index ba4b522..b736386 100644 --- a/crates/ghostty-config/src/command.rs +++ b/crates/ghostty-config/src/command.rs @@ -63,55 +63,14 @@ impl Iterator for ArgIterator { } } -/// Best-effort shell string splitter matching Zig's -/// `std.process.ArgIteratorGeneral(.{})` (whitespace separated, double -/// quotes group, backslashes only special before a double quote). +/// Zig's `std.process.ArgIteratorGeneral(.{})`. fn split_general(b: &[u8]) -> Vec { - let mut out = vec![]; - let mut i = 0; - loop { - // skip whitespace - while i < b.len() && (b[i] == b' ' || b[i] == b'\t') { - i += 1; - } - if i >= b.len() { - break; - } - - let mut cur: Vec = vec![]; - let mut quoted = false; - while i < b.len() { - match b[i] { - b' ' | b'\t' if !quoted => break, - b'\\' => { - let mut n = 0; - while i < b.len() && b[i] == b'\\' { - n += 1; - i += 1; - } - if i < b.len() && b[i] == b'"' { - cur.extend(std::iter::repeat_n(b'\\', n / 2)); - if n % 2 == 1 { - cur.push(b'"'); - i += 1; - } - } else { - cur.extend(std::iter::repeat_n(b'\\', n)); - } - } - b'"' => { - quoted = !quoted; - i += 1; - } - c => { - cur.push(c); - i += 1; - } - } - } - out.push(EncodedOs::from_bytes(&cur)); - } - out + ghostty_os::arg_iterator::ArgIteratorGeneral::new( + ghostty_os::arg_iterator::Options::default(), + b, + ) + .map(|arg| EncodedOs::from_bytes(&arg)) + .collect() } impl Command { @@ -259,6 +218,13 @@ mod tests { assert_eq!(args, ["echo", "hello world", "a\"b"]); } + #[test] + fn command_arg_iterator_shell_splits_cr_lf_tab_like_zig() { + let v = Command::Shell(EncodedOs::from_bytes(b"bash\n-i\r\tx \"a\nb\"\0ignored")); + let args: Vec = v.arg_iterator().collect(); + assert_eq!(args, ["bash", "-i", "x", "a\nb"]); + } + #[test] fn command_arg_iterator_direct() { let v = Command::Direct(vec!["echo".into(), "hello world".into()]); diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs index 426e381..01a52c3 100644 --- a/crates/ghostty-config/src/config.rs +++ b/crates/ghostty-config/src/config.rs @@ -4224,7 +4224,7 @@ impl Config { continue; } - let file = match path.to_path().and_then(std::fs::File::open) { + let file = match path.to_syscall_path().and_then(std::fs::File::open) { Ok(f) => f, Err(err) => { if err.kind() != std::io::ErrorKind::NotFound || !optional { @@ -4564,16 +4564,19 @@ impl Config { } // Clamp our mouse scroll multiplier + // (`@min(10_000, @max(0.01, v))`: NaN becomes the minimum.) + let zmin = cli_args::zig_min; + let zmax = cli_args::zig_max; self.mouse_scroll_multiplier.precision = - self.mouse_scroll_multiplier.precision.clamp(0.01, 10_000.0); + zmin(10_000.0, zmax(0.01, self.mouse_scroll_multiplier.precision)); self.mouse_scroll_multiplier.discrete = - self.mouse_scroll_multiplier.discrete.clamp(0.01, 10_000.0); + zmin(10_000.0, zmax(0.01, self.mouse_scroll_multiplier.discrete)); // Clamp our split opacity - self.unfocused_split_opacity = self.unfocused_split_opacity.clamp(0.15, 1.0); + self.unfocused_split_opacity = zmin(1.0, zmax(0.15, self.unfocused_split_opacity)); // Clamp our contrast - self.minimum_contrast = self.minimum_contrast.clamp(1.0, 21.0); + self.minimum_contrast = zmin(21.0, zmax(1.0, self.minimum_contrast)); // Minimum window size if self.window_width > 0 { @@ -4606,7 +4609,7 @@ impl Config { self.auto_update_channel = Some(ReleaseChannel::Stable); } - self.faint_opacity = self.faint_opacity.clamp(0.0, 1.0); + self.faint_opacity = cli_args::zig_clamp(self.faint_opacity, 0.0, 1.0); // Finalize key remapping set for efficient lookups self.key_remap.finalize(); @@ -4753,14 +4756,37 @@ impl Iterator for ChangeIterator<'_> { } } -/// Name of an IO error like Zig's error names (best effort), used in -/// user-visible diagnostics. +/// Name of an IO error like Zig's `{}` formatting of an error value +/// (`error.FileNotFound`), used in user-visible diagnostics. fn zig_io_error_name(err: &std::io::Error) -> String { use std::io::ErrorKind; + #[cfg(unix)] + if let Some(errno) = err.raw_os_error() { + let name = match errno { + 2 => Some("FileNotFound"), + 13 => Some("AccessDenied"), + 1 => Some("PermissionDenied"), + 21 => Some("IsDir"), + 20 => Some("NotDir"), + 40 => Some("SymLinkLoop"), + 36 => Some("NameTooLong"), + 24 => Some("ProcessFdQuotaExceeded"), + 23 => Some("SystemFdQuotaExceeded"), + 12 => Some("SystemResources"), + 19 => Some("NoDevice"), + 16 => Some("DeviceBusy"), + 26 => Some("FileBusy"), + 30 => Some("ReadOnlyFileSystem"), + _ => None, + }; + if let Some(name) = name { + return format!("error.{name}"); + } + } match err.kind() { - ErrorKind::NotFound => "FileNotFound".to_string(), - ErrorKind::PermissionDenied => "AccessDenied".to_string(), - _ => err.to_string(), + ErrorKind::NotFound => "error.FileNotFound".to_string(), + ErrorKind::PermissionDenied => "error.AccessDenied".to_string(), + _ => "error.Unexpected".to_string(), } } diff --git a/crates/ghostty-config/src/metric_modifier.rs b/crates/ghostty-config/src/metric_modifier.rs index 29d3f04..a6e7b7c 100644 --- a/crates/ghostty-config/src/metric_modifier.rs +++ b/crates/ghostty-config/src/metric_modifier.rs @@ -30,13 +30,18 @@ impl MetricModifier { /// Parses the modifier value. If the value ends in "%" it is assumed /// to be a percent, otherwise the value is parsed as an integer. pub fn parse(input: &str) -> Result { + Self::parse_bytes(input.as_bytes()) + } + + /// [`MetricModifier::parse`] over the original bytes (Zig never + /// validates UTF-8; every failure is `InvalidFormat`). + pub fn parse_bytes(input: &[u8]) -> Result { if input.is_empty() { return Err(ArgError::other("InvalidFormat")); } - if let Some(num) = input.strip_suffix('%') { - let mut percent = num - .parse::() + if let Some(num) = input.strip_suffix(b"%") { + let mut percent = ghostty_os::zig_fmt::parse_float::(num) .map_err(|_| ArgError::other("InvalidFormat"))?; percent /= 100.0; @@ -46,15 +51,14 @@ impl MetricModifier { return Ok(MetricModifier::Percent(1.0 + percent)); } - input - .parse::() + ghostty_os::zig_fmt::parse_int::(input, 10) .map(MetricModifier::Absolute) .map_err(|_| ArgError::other("InvalidFormat")) } /// So it works with the config framework. - pub fn parse_cli_value(input: Option<&str>) -> Result { - Self::parse(input.ok_or(ArgError::ValueRequired)?) + pub fn parse_cli_value(input: Option<&[u8]>) -> Result { + Self::parse_bytes(input.ok_or(ArgError::ValueRequired)?) } /// Apply a modifier to an integer value (saturating, clamped at zero for @@ -100,14 +104,16 @@ impl MetricModifier { impl ConfigField for MetricModifier { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { - let value = crate::cli_args::text_value(value)?; *self = Self::parse_cli_value(value)?; Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { let s = match self { - MetricModifier::Percent(v) => format!("{}%", (v - 1.0) * 100.0), + MetricModifier::Percent(v) => format!( + "{}%", + ghostty_os::zig_fmt::format_float_d((v - 1.0) * 100.0) + ), MetricModifier::Absolute(v) => format!("{v}"), }; f.format_entry(&s) diff --git a/crates/ghostty-config/src/path.rs b/crates/ghostty-config/src/path.rs index 94c058d..5414db7 100644 --- a/crates/ghostty-config/src/path.rs +++ b/crates/ghostty-config/src/path.rs @@ -154,7 +154,7 @@ impl Path { // Make sure the base directory can be opened. std::fs::read_dir(&base_path)?; - let full = base_path.join(path.to_path()?); + let full = base_path.join(path.to_syscall_path()?); let abs = match std::fs::canonicalize(&full) { Ok(p) => EncodedOs::from_path(&p), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -392,6 +392,31 @@ mod tests { assert!(diags.is_empty()); } + #[cfg(unix)] + #[test] + fn expand_interior_nul_resolves_the_c_string_prefix_like_zig() { + let tmp = ghostty_os::TempDir::init().unwrap(); + let base = std::fs::canonicalize(tmp.path()).unwrap(); + std::fs::write(base.join("exists"), b"x").unwrap(); + + // The syscall sees only the prefix, which exists: the real path of + // the prefix replaces the value. + let mut p = Path::Required(EncodedOs::from_bytes(b"exists\0tail")); + let mut diags = DiagnosticList::default(); + p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap(); + assert_eq!(diags.items().len(), 0); + assert_eq!(p.path().to_path().unwrap(), base.join("exists")); + + // The prefix is missing: the whole value is resolved lexically. + let mut p = Path::Required(EncodedOs::from_bytes(b"missing\0tail")); + p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap(); + assert_eq!(diags.items().len(), 0); + assert_eq!( + p.path().to_bytes(), + [base.as_os_str().as_encoded_bytes(), b"/missing\0tail"].concat() + ); + } + #[cfg(unix)] #[test] fn expand_symlink_to_non_utf8_target_round_trips() { diff --git a/crates/ghostty-config/src/types_a.rs b/crates/ghostty-config/src/types_a.rs index 1555021..db28bcd 100644 --- a/crates/ghostty-config/src/types_a.rs +++ b/crates/ghostty-config/src/types_a.rs @@ -8,7 +8,7 @@ use ghostty_vt::color::{DEFAULT as DEFAULT_PALETTE, Palette as TermPalette, Pale use crate::{ c_value::CValue, - cli_args::{ArgError, ConfigField, parse_int_base0, trim_ws}, + cli_args::{ArgError, ConfigField, trim_ws}, clipboard_codepoint_map::{ClipboardCodepointMap, Entry as ClipboardEntry, Replacement}, config_enum, config_packed, @@ -242,11 +242,18 @@ impl Color { /// Zig: `Color.parseCLI(input)` (static form returning a new value). pub fn from_cli(input: Option<&str>) -> Result { + Color::from_cli_bytes(input.map(str::as_bytes)) + } + + /// [`Color::from_cli`] over the original bytes: Zig never validates + /// UTF-8 here, so a stray byte is an `InvalidCharacter` (or + /// `InvalidValue` for a wrong length), not a generic invalid value. + pub fn from_cli_bytes(input: Option<&[u8]>) -> Result { let input = input.ok_or(ArgError::ValueRequired)?; // Trim any whitespace before processing - let trimmed = trim_ws(input); + let trimmed = crate::cli_args::trim_bytes(input); - if let Some(rgb) = ghostty_vt::x11_color::get(trimmed) { + if let Some(rgb) = ghostty_vt::x11_color::get_bytes(trimmed) { return Ok(Color { r: rgb.r, g: rgb.g, @@ -254,7 +261,7 @@ impl Color { }); } - Color::from_hex(trimmed) + Color::from_hex_bytes(trimmed) } /// Format the color as a string. @@ -266,8 +273,13 @@ impl Color { /// fromHex parses a color from a hex value such as #RRGGBB. The "#" /// is optional. pub fn from_hex(input: &str) -> Result { + Color::from_hex_bytes(input.as_bytes()) + } + + /// [`Color::from_hex`] over bytes. + pub fn from_hex_bytes(input: &[u8]) -> Result { // Trim the beginning '#' if it exists - let trimmed = input.strip_prefix('#').unwrap_or(input).as_bytes(); + let trimmed = input.strip_prefix(b"#").unwrap_or(input); if trimmed.len() != 6 && trimmed.len() != 3 { return Err(ArgError::InvalidValue); } @@ -282,7 +294,7 @@ impl Color { }; let digit = |c: u8| -> Result { - (c as char) + char::from(c) .to_digit(16) .map(|d| d as u8) .ok_or_else(|| ArgError::other("InvalidCharacter")) @@ -308,8 +320,7 @@ impl ConfigField for Color { } fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> { - let input = crate::cli_args::text_value(input)?; - *self = Color::from_cli(input)?; + *self = Color::from_cli_bytes(input)?; Ok(()) } @@ -336,11 +347,16 @@ impl Default for TerminalColor { impl TerminalColor { /// Zig: `TerminalColor.parseCLI(input)`. pub fn from_cli(input: Option<&str>) -> Result { + TerminalColor::from_cli_bytes(input.map(str::as_bytes)) + } + + /// [`TerminalColor::from_cli`] over bytes. + pub fn from_cli_bytes(input: Option<&[u8]>) -> Result { let input = input.ok_or(ArgError::ValueRequired)?; match input { - "cell-foreground" => Ok(TerminalColor::CellForeground), - "cell-background" => Ok(TerminalColor::CellBackground), - _ => Ok(TerminalColor::Color(Color::from_cli(Some(input))?)), + b"cell-foreground" => Ok(TerminalColor::CellForeground), + b"cell-background" => Ok(TerminalColor::CellBackground), + _ => Ok(TerminalColor::Color(Color::from_cli_bytes(Some(input))?)), } } @@ -355,8 +371,7 @@ impl TerminalColor { impl ConfigField for TerminalColor { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> { - let input = crate::cli_args::text_value(input)?; - *self = TerminalColor::from_cli(input)?; + *self = TerminalColor::from_cli_bytes(input)?; Ok(()) } @@ -385,18 +400,22 @@ impl Default for BoldColor { impl BoldColor { /// Zig: `BoldColor.parseCLI(input)`. pub fn from_cli(input: Option<&str>) -> Result { + BoldColor::from_cli_bytes(input.map(str::as_bytes)) + } + + /// [`BoldColor::from_cli`] over bytes. + pub fn from_cli_bytes(input: Option<&[u8]>) -> Result { let input = input.ok_or(ArgError::ValueRequired)?; - if input == "bright" { + if input == b"bright" { return Ok(BoldColor::Bright); } - Ok(BoldColor::Color(Color::from_cli(Some(input))?)) + Ok(BoldColor::Color(Color::from_cli_bytes(Some(input))?)) } } impl ConfigField for BoldColor { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> { - let input = crate::cli_args::text_value(input)?; - *self = BoldColor::from_cli(input)?; + *self = BoldColor::from_cli_bytes(input)?; Ok(()) } @@ -419,7 +438,6 @@ impl ConfigField for ColorList { } fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> { - let input = crate::cli_args::text_value(input)?; let input = input.ok_or(ArgError::ValueRequired)?; if input.is_empty() { return Err(ArgError::ValueRequired); @@ -430,15 +448,15 @@ impl ConfigField for ColorList { // Split the input by commas and parse each color let mut count = 0usize; - for raw in input.split(',').filter(|s| !s.is_empty()) { + for raw in input.split(|b| *b == b',').filter(|s| !s.is_empty()) { count += 1; if count > 64 { return Err(ArgError::InvalidValue); } // Trim whitespace from each color value - let trimmed = trim_ws(raw); - let color = Color::from_cli(Some(trimmed))?; + let trimmed = crate::cli_args::trim_bytes(raw); + let color = Color::from_cli_bytes(Some(trimmed))?; self.colors.push(color); } @@ -488,21 +506,26 @@ impl ConfigField for Palette { } fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> { - let input = crate::cli_args::text_value(input)?; let value = input.ok_or(ArgError::ValueRequired)?; - let eql_idx = value.find('=').ok_or(ArgError::InvalidValue)?; + let eql_idx = value + .iter() + .position(|b| *b == b'=') + .ok_or(ArgError::InvalidValue)?; // Parse the key part (trim whitespace) - let key_str = trim_ws(&value[..eql_idx]); - let key = match parse_int_base0(key_str) { - None => return Err(ArgError::other("InvalidCharacter")), - Some(n) if n < 0 => return Err(ArgError::other("InvalidCharacter")), - Some(n) if n > 255 => return Err(ArgError::other("Overflow")), - Some(n) => n as usize, + let key_str = crate::cli_args::trim_bytes(&value[..eql_idx]); + let key = match ghostty_os::zig_fmt::parse_int::(key_str, 0) { + Ok(n) => usize::from(n), + Err(ghostty_os::zig_fmt::ParseIntError::InvalidCharacter) => { + return Err(ArgError::other("InvalidCharacter")); + } + Err(ghostty_os::zig_fmt::ParseIntError::Overflow) => { + return Err(ArgError::other("Overflow")); + } }; // Parse the color part (Color parsing will handle whitespace) - let rgb = Color::from_cli(Some(&value[eql_idx + 1..]))?; + let rgb = Color::from_cli_bytes(Some(&value[eql_idx + 1..]))?; self.value[key] = rgb.to_terminal_rgb(); self.mask.set(key); Ok(()) @@ -698,7 +721,8 @@ impl ConfigField for RepeatableFontVariation { let key: &[u8; 4] = key.try_into().map_err(|_| ArgError::InvalidValue)?; self.list.push(Variation { id: VariationId::init(key), - value: value.parse::().map_err(|_| ArgError::InvalidValue)?, + value: ghostty_os::zig_fmt::parse_float::(value.as_bytes()) + .map_err(|_| ArgError::InvalidValue)?, }); Ok(()) } @@ -861,8 +885,7 @@ impl<'a> UnicodeRangeParser<'a> { /// `std.fmt.parseInt(u21, s, 16)` for plain hex digit strings. fn parse_u21_hex(s: &[u8]) -> Option { - let s = std::str::from_utf8(s).ok()?; - let v = u32::from_str_radix(s, 16).ok()?; + let v = ghostty_os::zig_fmt::parse_int::(s, 16).ok()?; (v <= 0x1f_ffff).then_some(v) } @@ -1054,7 +1077,6 @@ pub struct RepeatableLink { impl ConfigField for RepeatableLink { fn parse_bytes(&mut self, _input: Option<&[u8]>) -> Result<(), ArgError> { - let _input = crate::cli_args::text_value(_input)?; Err(ArgError::other("NotImplemented")) } diff --git a/crates/ghostty-config/src/types_b.rs b/crates/ghostty-config/src/types_b.rs index 2be64ce..70c0301 100644 --- a/crates/ghostty-config/src/types_b.rs +++ b/crates/ghostty-config/src/types_b.rs @@ -413,7 +413,8 @@ impl ConfigField for MouseScrollMultiplier { match r { Ok(()) => *self = result, Err(ArgError::InvalidValue) => { - let v: f64 = input.parse().map_err(|_| ArgError::InvalidValue)?; + let v = ghostty_os::zig_fmt::parse_float::(input.as_bytes()) + .map_err(|_| ArgError::InvalidValue)?; *self = MouseScrollMultiplier { precision: v, discrete: v, @@ -427,7 +428,11 @@ impl ConfigField for MouseScrollMultiplier { fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { fmt_str( f, - &format!("precision:{},discrete:{}", self.precision, self.discrete), + &format!( + "precision:{},discrete:{}", + ghostty_os::zig_fmt::format_float_d(self.precision), + ghostty_os::zig_fmt::format_float_d(self.discrete) + ), ) } } @@ -537,19 +542,21 @@ impl QuickTerminalSizeValue { } } - pub fn parse(input: &str) -> Result { + pub fn parse(input: &[u8]) -> Result { if input.is_empty() { return Err(ArgError::ValueRequired); } - if let Some(num) = input.strip_suffix("px") { + if let Some(num) = input.strip_suffix(b"px") { return Ok(Self::Pixels( - num.parse::().map_err(|_| ArgError::InvalidValue)?, + ghostty_os::zig_fmt::parse_int::(num, 10) + .map_err(|_| ArgError::InvalidValue)?, )); } - if let Some(num) = input.strip_suffix('%') { - let percentage = num.parse::().map_err(|_| ArgError::InvalidValue)?; + if let Some(num) = input.strip_suffix(b"%") { + let percentage = + ghostty_os::zig_fmt::parse_float::(num).map_err(|_| ArgError::InvalidValue)?; if percentage < 0.0 { return Err(ArgError::InvalidValue); } @@ -630,15 +637,16 @@ impl ConfigField for QuickTerminalSize { } fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> { - let input = crate::cli_args::text_value(input)?; let input = input.ok_or(ArgError::ValueRequired)?; - let mut it = input.split(','); + let mut it = input.split(|b| *b == b','); - let primary = trim_ws(it.next().ok_or(ArgError::ValueRequired)?); + let primary = crate::cli_args::trim_bytes(it.next().ok_or(ArgError::ValueRequired)?); self.primary = Some(QuickTerminalSizeValue::parse(primary)?); self.secondary = match it.next() { - Some(s) => Some(QuickTerminalSizeValue::parse(trim_ws(s))?), + Some(s) => Some(QuickTerminalSizeValue::parse(crate::cli_args::trim_bytes( + s, + ))?), None => None, }; @@ -824,15 +832,17 @@ impl ConfigField for BackgroundBlur { } fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> { - let input = crate::cli_args::text_value(input)?; let Some(input) = input else { // Emulate behavior for bools *self = BackgroundBlur::True; return Ok(()); }; + // Only the bool/enum spellings need text; everything else is a + // `parseInt` over the raw bytes. + let text = std::str::from_utf8(input).unwrap_or("\u{fffd}"); // Try to parse normal bools - if let Ok(b) = parse_bool(input) { + if let Ok(b) = parse_bool(text) { *self = if b { BackgroundBlur::True } else { @@ -842,7 +852,7 @@ impl ConfigField for BackgroundBlur { } // Try to parse enums (only void variants can be parsed this way) - match input { + match text { "macos-glass-regular" => { *self = BackgroundBlur::MacosGlassRegular; return Ok(()); @@ -854,11 +864,20 @@ impl ConfigField for BackgroundBlur { _ => {} } - let mut r = 0u8; - r.parse_cli(Some(input)) - .map_err(|_| ArgError::InvalidValue)?; + // Zig writes `self.* = .{ .radius = parseInt(...) catch return ... }` + // in place: the union tag is switched to `radius` before the payload + // is computed, so a failed parse leaves a `radius` variant behind + // (observable in the oracle: an invalid value formats as `0`). + let mut r = match *self { + BackgroundBlur::Radius(v) => v, + _ => 0, + }; + let parsed = ghostty_os::zig_fmt::parse_int::(input, 0); + if let Ok(v) = parsed { + r = v; + } *self = BackgroundBlur::Radius(r); - Ok(()) + parsed.map(|_| ()).map_err(|_| ArgError::InvalidValue) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { @@ -1028,14 +1047,7 @@ pub const DURATION_UNITS: &[(&str, u64)] = &[ /// `std.fmt.parseUnsigned(u64, s, 10)`. fn parse_unsigned_u64(s: &[u8]) -> Option { - if s.is_empty() || !s.iter().all(u8::is_ascii_digit) { - return None; - } - let mut acc: u64 = 0; - for &c in s { - acc = acc.checked_mul(10)?.checked_add(u64::from(c - b'0'))?; - } - Some(acc) + ghostty_os::zig_fmt::parse_unsigned::(s, 10).ok() } impl Duration { @@ -1201,7 +1213,10 @@ pub struct WindowPadding { impl WindowPadding { pub fn parse(input: Option<&str>) -> Result { let input = input.ok_or(ArgError::ValueRequired)?; - let p = |s: &str| s.parse::().map_err(|_| ArgError::InvalidValue); + let p = |s: &str| { + ghostty_os::zig_fmt::parse_int::(s.as_bytes(), 10) + .map_err(|_| ArgError::InvalidValue) + }; if let Some(idx) = input.find(',') { let left = p(trim_ws(&input[..idx]))?; diff --git a/crates/ghostty-config/tests/zig_parity.rs b/crates/ghostty-config/tests/zig_parity.rs new file mode 100644 index 0000000..815dc53 --- /dev/null +++ b/crates/ghostty-config/tests/zig_parity.rs @@ -0,0 +1,147 @@ +//! Parity regressions found by differential runs against Ghostty 1.3.1: +//! numeric grammars, field-specific error kinds for non-UTF-8 input and +//! float formatting. +#![allow(clippy::unwrap_used)] +use ghostty_config::{cli_args::ArgError, config::Config}; + +fn parse(key: &str, value: &[u8]) -> Result { + let mut config = Config::default(); + config.parse_field_bytes(key, Some(value))?; + Ok(config) +} + +fn kind(key: &str, value: &[u8]) -> String { + parse(key, value).map_or_else(|e| e.to_string(), |_| "ok".to_string()) +} + +#[test] +fn numeric_grammars_accept_zig_spellings() { + assert_eq!(parse("window-height", b"0X10").unwrap().window_height, 16); + assert_eq!(parse("window-height", b"1__0").unwrap().window_height, 10); + assert_eq!(parse("window-height", b"0b1_0").unwrap().window_height, 2); + assert_eq!(parse("font-size", b"1_4").unwrap().font_size, 14.0); + assert_eq!(parse("font-size", b"0x1.cp+3").unwrap().font_size, 14.0); + assert_eq!( + parse("window-padding-x", b"1_0") + .unwrap() + .window_padding_x + .top_left, + 10 + ); + assert_eq!(kind("window-padding-x", b"0x10"), "InvalidValue"); + assert_eq!(kind("window-height", b"1_"), "InvalidValue"); + assert_eq!(kind("font-size", b"1__0"), "InvalidValue"); + // Decimal-only places keep rejecting prefixes. + assert_eq!(kind("adjust-cell-width", b"0x10"), "InvalidFormat"); + assert_eq!(kind("adjust-cell-width", b"1_0"), "ok"); + assert_eq!(kind("adjust-cell-width", b"0x1p3%"), "ok"); + assert_eq!(kind("quick-terminal-size", b"1_0px"), "ok"); + assert_eq!(kind("quick-terminal-size", b"0x1p3%,1_0px"), "ok"); + assert_eq!(kind("quick-terminal-size", b"0x10px"), "InvalidValue"); + assert_eq!(kind("font-variation", b"wght=1_0.5"), "ok"); + assert_eq!(kind("font-variation", b"wght=0x1p4"), "ok"); + assert_eq!(kind("mouse-scroll-multiplier", b"1_0"), "ok"); + assert_eq!(kind("palette", b"0x10=#fff"), "ok"); + assert_eq!(kind("palette", b"256=#fff"), "Overflow"); + assert_eq!(kind("palette", b"-1=#fff"), "Overflow"); + assert_eq!(kind("palette", b"-0=#fff"), "ok"); + assert_eq!(kind("palette", b"1x=#fff"), "InvalidCharacter"); + assert_eq!(kind("background-blur", b"0b11"), "ok"); + assert_eq!(kind("keybind", b"a=scroll_page_lines:1_0"), "ok"); + assert_eq!( + kind("keybind", b"a=scroll_page_lines:0x10"), + "InvalidFormat" + ); + assert_eq!(kind("keybind", b"a=set_font_size:1_4"), "ok"); + assert_eq!(kind("keybind", b"a=set_font_size:0x1.cp+3"), "ok"); + assert_eq!(kind("keybind", b"a=resize_split:up,1_0"), "ok"); + assert_eq!(kind("keybind", b"a=goto_tab:1__0"), "ok"); + assert_eq!(kind("keybind", b"a=goto_tab:_1"), "InvalidFormat"); +} + +#[test] +fn invalid_bytes_keep_field_specific_error_kinds() { + for key in [ + "adjust-cell-width", + "adjust-cursor-height", + "adjust-icon-height", + ] { + assert_eq!(kind(key, b"\xff"), "InvalidFormat", "{key}"); + assert_eq!(kind(key, b"10px\xff"), "InvalidFormat", "{key}"); + assert_eq!(kind(key, b"\xff%"), "InvalidFormat", "{key}"); + } + assert_eq!(kind("quick-terminal-size", b"\xff"), "MissingUnit"); + assert_eq!(kind("quick-terminal-size", b"a\xffb"), "MissingUnit"); + assert_eq!(kind("quick-terminal-size", b"\xff%"), "InvalidValue"); + assert_eq!(kind("quick-terminal-size", b"1px,\xff"), "MissingUnit"); + assert_eq!(kind("link", b"\xff"), "NotImplemented"); + assert_eq!(kind("link", b"x"), "NotImplemented"); + for key in [ + "background", + "foreground", + "cursor-color", + "bold-color", + "split-divider-color", + ] { + assert_eq!(kind(key, b"a\xffb"), "InvalidCharacter", "{key}"); + assert_eq!(kind(key, b"\xff"), "InvalidValue", "{key}"); + } + assert_eq!(kind("palette", b"1=a\xffb"), "InvalidCharacter"); + assert_eq!(kind("palette", b"1=\xff"), "InvalidValue"); + assert_eq!(kind("keybind", b"\xff=a"), "InvalidAction"); + assert_eq!(kind("keybind", b"\xff=ignore"), "InvalidFormat"); + assert_eq!(kind("keybind", b"ctrl+a=goto_tab:\xff"), "InvalidFormat"); + assert_eq!(kind("keybind", b"ctrl+a=nope:\xff"), "InvalidAction"); + assert_eq!(kind("keybind", b"ctrl+a=text:\xff"), "ok"); +} + +#[test] +fn failed_background_blur_parse_leaves_a_zero_radius_like_zig() { + use ghostty_config::types_b::BackgroundBlur; + let mut config = Config::default(); + assert!( + config + .parse_field_bytes("background-blur", Some(b"bad")) + .is_err() + ); + assert_eq!(config.background_blur, BackgroundBlur::Radius(0)); + config + .parse_field_bytes("background-blur", Some(b"7")) + .unwrap(); + assert!( + config + .parse_field_bytes("background-blur", Some(b"\xff")) + .is_err() + ); + assert_eq!(config.background_blur, BackgroundBlur::Radius(7)); +} + +#[test] +fn float_nan_formats_like_zig_and_finalize_clamps_like_zig() { + let mut config = parse("font-size", b"nan").unwrap(); + let mut out = String::new(); + ghostty_config::formatter::format_entry("font-size", &config.font_size, &mut out).unwrap(); + assert_eq!(out, "font-size = nan\n"); + + config + .parse_field_bytes("faint-opacity", Some(b"nan")) + .unwrap(); + config + .parse_field_bytes("unfocused-split-opacity", Some(b"nan")) + .unwrap(); + config + .parse_field_bytes("minimum-contrast", Some(b"nan")) + .unwrap(); + config + .parse_field_bytes("mouse-scroll-multiplier", Some(b"nan")) + .unwrap(); + config.finalize().unwrap(); + assert_eq!(config.faint_opacity, 1.0); + assert_eq!(config.unfocused_split_opacity, 0.15); + assert_eq!(config.minimum_contrast, 1.0); + assert_eq!(config.mouse_scroll_multiplier.precision, 0.01); + + let mut config = parse("faint-opacity", b"-0").unwrap(); + config.finalize().unwrap(); + assert!(config.faint_opacity.is_sign_positive()); +} diff --git a/crates/ghostty-font/src/discovery.rs b/crates/ghostty-font/src/discovery.rs index c72e4f6..ccb1559 100644 --- a/crates/ghostty-font/src/discovery.rs +++ b/crates/ghostty-font/src/discovery.rs @@ -162,6 +162,43 @@ mod fc_impl { mod tests { use super::*; + #[cfg(font_fontconfig)] + #[test] + fn interior_nul_in_font_names_ends_the_c_string() { + use ghostty_config::config::Config; + use ghostty_os::osbytes::os_bytes; + + use crate::fontconfig::Property; + let mut config = Config::default(); + config + .parse_field_bytes("font-family", Some(b"monospace\0suffix")) + .unwrap(); + config + .parse_field_bytes("font-style", Some(b"Regular\0suffix")) + .unwrap(); + config + .parse_field_bytes("font-codepoint-map", Some(b"U+0041=Fam\0ily")) + .unwrap(); + let pat = to_fc_pattern(&Descriptor { + family: Some(config.font_family.list[0].clone()), + style: config.font_style.name_value().cloned(), + ..Descriptor::default() + }); + assert_eq!( + os_bytes(&pat.get_os_string(Property::Family, 0).unwrap()), + b"monospace" + ); + assert_eq!( + os_bytes(&pat.get_os_string(Property::Style, 0).unwrap()), + b"Regular" + ); + let pat = to_fc_pattern(&config.font_codepoint_map.map.list[0].descriptor); + assert_eq!( + os_bytes(&pat.get_os_string(Property::Family, 0).unwrap()), + b"Fam" + ); + } + #[cfg(font_fontconfig)] #[test] fn configured_font_names_reach_fontconfig_as_original_bytes() { diff --git a/crates/ghostty-font/src/fontconfig.rs b/crates/ghostty-font/src/fontconfig.rs index 362ab40..b507b76 100644 --- a/crates/ghostty-font/src/fontconfig.rs +++ b/crates/ghostty-font/src/fontconfig.rs @@ -189,8 +189,10 @@ impl Pattern { self.add_string_bytes(prop, v.as_bytes()) } - /// [`Pattern::add_string`] for arbitrary bytes (file names). + /// [`Pattern::add_string`] for arbitrary bytes (file names). Like Zig's + /// sentinel-terminated C string handoff, the value ends at the first NUL. pub fn add_string_bytes(&mut self, prop: Property, v: &[u8]) -> bool { + let v = v.split(|&b| b == 0).next().unwrap_or(v); let Ok(c) = CString::new(v) else { return false; }; diff --git a/crates/ghostty-input/src/binding/action.rs b/crates/ghostty-input/src/binding/action.rs index 1c8813f..075961c 100644 --- a/crates/ghostty-input/src/binding/action.rs +++ b/crates/ghostty-input/src/binding/action.rs @@ -177,10 +177,8 @@ impl SplitResizeParameter { pub(super) fn parse_param(param: &str) -> Result { 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::() + let amt = it.next().ok_or(Error::InvalidFormat)?; + let amt = ghostty_os::zig_fmt::parse_int::(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; } -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 { + ghostty_os::zig_fmt::parse_float::(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(':'); diff --git a/crates/ghostty-input/src/binding/mod.rs b/crates/ghostty-input/src/binding/mod.rs index 91e78a8..e670170 100644 --- a/crates/ghostty-input/src/binding/mod.rs +++ b/crates/ghostty-input/src/binding/mod.rs @@ -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::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 { 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 { diff --git a/crates/ghostty-lib/src/action_c.rs b/crates/ghostty-lib/src/action_c.rs index 363e293..86df4b8 100644 --- a/crates/ghostty-lib/src/action_c.rs +++ b/crates/ghostty-lib/src/action_c.rs @@ -34,16 +34,15 @@ pub struct Keep { } impl Keep { - /// A NUL terminated copy of `s` (interior NULs are dropped). + /// A NUL terminated copy of `s` (it ends at the first interior NUL). fn cstr(&mut self, s: &str) -> *const c_char { self.cstr_bytes(s.as_bytes()) } /// [`Keep::cstr`] for raw bytes. fn cstr_bytes(&mut self, s: &[u8]) -> *const c_char { - let mut s = s.to_vec(); - s.retain(|&b| b != 0); - let c = CString::new(s).unwrap_or_default(); + let end = s.iter().position(|&b| b == 0).unwrap_or(s.len()); + let c = CString::new(&s[..end]).unwrap_or_default(); let ptr = c.as_ptr(); // The heap buffer of a CString doesn't move when the Vec grows. self.strings.push(c); @@ -241,6 +240,26 @@ mod tests { use super::*; + #[test] + fn c_title_actions_end_at_the_first_interior_nul() { + use ghostty_os::osbytes::EncodedOs; + let mut keep = Keep::default(); + for tab in [false, true] { + let title = SetTitle { + title: EncodedOs::from_bytes(b"a\0b"), + }; + let action = if tab { + Action::SetTabTitle(title) + } else { + Action::SetTitle(title) + }; + let c = action_to_c(&action, &mut keep); + // SAFETY: both title union members have identical layout, pointer owned by keep. + let title = unsafe { std::ffi::CStr::from_ptr(c.action.set_title.title) }; + assert_eq!(title.to_bytes(), b"a"); + } + } + #[test] fn c_title_actions_and_key_table_names_forward_original_bytes() { use ghostty_os::osbytes::EncodedOs; diff --git a/crates/ghostty-lib/src/config_capi.rs b/crates/ghostty-lib/src/config_capi.rs index 8303589..6deb96a 100644 --- a/crates/ghostty-lib/src/config_capi.rs +++ b/crates/ghostty-lib/src/config_capi.rs @@ -98,18 +98,23 @@ impl CConfig { } } -/// A C string without interior NULs. +/// A C string over `bytes` that ends at the first NUL, like a C consumer +/// reading Zig's backing bytes (the rest is never visible through the pointer). +fn cstring_until_nul(bytes: &[u8]) -> CString { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + CString::new(&bytes[..end]).unwrap_or_default() +} + +/// A C string for a UTF-8 string, ending at the first NUL. fn cstring(s: &str) -> CString { - CString::new(s.replace('\0', "")).unwrap_or_default() + cstring_until_nul(s.as_bytes()) } /// A C string for a config *value*: config strings carry non-UTF-8 bytes in /// the reversible [`ghostty_os::osbytes`] encoding, so the embedder gets the /// decoded bytes (Zig hands out the original byte slice). fn cstring_value(s: &str) -> CString { - let mut bytes = ghostty_os::osbytes::config_str_to_bytes(s); - bytes.retain(|b| *b != 0); - CString::new(bytes).unwrap_or_default() + cstring_until_nul(&ghostty_os::osbytes::config_str_to_bytes(s)) } /// Intern a string for the lifetime of the process (enum tag names). @@ -614,6 +619,23 @@ mod tests { assert_eq!(b.strings[0].to_str().unwrap(), "h\u{e9}llo"); } + #[test] + fn config_string_values_end_at_the_first_interior_nul() { + use ghostty_os::osbytes::EncodedOs; + let enc = EncodedOs::from_bytes(b"a\0b"); + let b = build_backing(&CValue::String(enc.into_encoded())); + assert_eq!(b.strings[0].as_bytes(), b"a"); + let b = build_backing(&CValue::String("x\0y".to_owned())); + assert_eq!(b.strings[0].as_bytes(), b"x"); + let enc = EncodedOs::from_bytes(b"\0lead"); + let b = build_backing(&CValue::Path { + path: enc.into_encoded(), + optional: false, + }); + assert_eq!(b.strings[0].as_bytes(), b""); + assert_eq!(intern("ab\0c"), intern("ab")); + } + fn get(cfg: ghostty_config_t, key: &str, init: T) -> (bool, T) { let mut out = init; // SAFETY: `out` is a valid destination of the key's C type. diff --git a/crates/ghostty-lib/src/embedded.rs b/crates/ghostty-lib/src/embedded.rs index 687dd99..451c63a 100644 --- a/crates/ghostty-lib/src/embedded.rs +++ b/crates/ghostty-lib/src/embedded.rs @@ -626,9 +626,8 @@ impl RtSurface for SurfaceRt { .iter() .map(|(m, d)| { ( - CString::new(m.replace('\0', "")).unwrap_or_default(), - CString::new(d.iter().copied().filter(|b| *b != 0).collect::>()) - .unwrap_or_default(), + nul_terminated_prefix(m.as_bytes()), + nul_terminated_prefix(d), ) }) .collect(); @@ -2157,6 +2156,13 @@ mod darwin { } } +/// A C string that ends at the first interior NUL, like a C consumer of a +/// Zig sentinel-terminated slice. +fn nul_terminated_prefix(bytes: &[u8]) -> CString { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + CString::new(&bytes[..end]).unwrap_or_default() +} + #[cfg(test)] mod tests { use std::os::unix::ffi::OsStrExt; diff --git a/crates/ghostty-os/src/arg_iterator.rs b/crates/ghostty-os/src/arg_iterator.rs new file mode 100644 index 0000000..c7bd203 --- /dev/null +++ b/crates/ghostty-os/src/arg_iterator.rs @@ -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> { + if !self.skip_whitespace() { + return None; + } + + let mut out: Vec = Vec::new(); + let mut backslash_count: usize = 0; + let mut in_quote = false; + let emit_backslashes = + |out: &mut Vec, 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; + + fn next(&mut self) -> Option> { + 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> = it.collect(); + let want: Vec> = 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> { + 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::>::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()); + } +} diff --git a/crates/ghostty-os/src/desktop.rs b/crates/ghostty-os/src/desktop.rs index 6542a18..fe213d7 100644 --- a/crates/ghostty-os/src/desktop.rs +++ b/crates/ghostty-os/src/desktop.rs @@ -33,11 +33,12 @@ pub fn launched_from_desktop() -> bool { // (`std::process::id` is `getpid` / `GetCurrentProcessId`.) let pid = std::process::id(); - let Ok(gio_pid) = gio_pid_str.to_string_lossy().parse::() else { + // (`parseInt(c_int, ..., 10)`.) + let Ok(gio_pid) = crate::zig_fmt::parse_int::(gio_pid_str.as_encoded_bytes(), 10) else { return false; }; - gio_pid == pid + i64::from(gio_pid) == i64::from(pid) } /// Environment variable set by the macOS app to tell libghostty how it was diff --git a/crates/ghostty-os/src/lib.rs b/crates/ghostty-os/src/lib.rs index 59891a2..479f7dd 100644 --- a/crates/ghostty-os/src/lib.rs +++ b/crates/ghostty-os/src/lib.rs @@ -14,6 +14,7 @@ ) )] +pub mod arg_iterator; pub mod args; #[cfg(not(target_arch = "wasm32"))] pub mod cf_release_thread; @@ -31,6 +32,7 @@ pub mod file_type; #[cfg(unix)] pub mod flatpak; pub mod osbytes; +pub mod zig_fmt; #[cfg(windows)] pub mod flatpak { //! Flatpak does not exist on Windows. diff --git a/crates/ghostty-os/src/osbytes.rs b/crates/ghostty-os/src/osbytes.rs index 25ca6df..36bda32 100644 --- a/crates/ghostty-os/src/osbytes.rs +++ b/crates/ghostty-os/src/osbytes.rs @@ -431,6 +431,17 @@ impl EncodedOs { } } + /// [`EncodedOs::to_path`] as a syscall sees it: Zig hands paths to the + /// OS as NUL-terminated C strings, so an interior NUL ends the path + /// (Rust's `std::fs` would refuse the whole path instead). + pub fn to_syscall_path(&self) -> std::io::Result { + let bytes = self.to_bytes(); + match bytes.iter().position(|&b| b == 0) { + Some(end) => EncodedOs::from_bytes(&bytes[..end]).to_path(), + None => self.to_path(), + } + } + /// Whether the decoded value starts with the given plain prefix. #[must_use] pub fn starts_with_plain(&self, prefix: &str) -> bool { @@ -526,6 +537,22 @@ pub fn config_str_to_path(s: &str) -> std::io::Result { mod tests { use super::*; + #[cfg(unix)] + #[test] + fn syscall_path_ends_at_the_first_nul() { + let e = EncodedOs::from_bytes(b"/a/b\0c"); + assert_eq!( + e.to_syscall_path().unwrap(), + std::path::PathBuf::from("/a/b") + ); + let e = EncodedOs::from_bytes(b"/a/\xffb"); + assert_eq!(e.to_syscall_path().unwrap(), e.to_path().unwrap()); + assert_eq!( + EncodedOs::from_bytes(b"\0x").to_syscall_path().unwrap(), + std::path::PathBuf::new() + ); + } + #[test] fn windows_paths_fail_with_bad_path_name_instead_of_replacement() { let error = windows_path_wide(b"C:\\bad\xff").unwrap_err(); diff --git a/crates/ghostty-os/src/zig_fmt.rs b/crates/ghostty-os/src/zig_fmt.rs new file mode 100644 index 0000000..7b14cc9 --- /dev/null +++ b/crates/ghostty-os/src/zig_fmt.rs @@ -0,0 +1,872 @@ +//! Port of Zig 0.16 `std.fmt.parseInt`, `std.fmt.parseUnsigned` and +//! `std.fmt.parseFloat` (`fmt.zig`, `fmt/parse_float.zig` and +//! `fmt/parse_float/{parse,convert_hex,FloatStream}.zig`). +//! +//! The grammars are reproduced exactly: underscores, `0b`/`0o`/`0x` +//! prefixes (both cases, base 0 only), sign rules, hex floats, `inf` / +//! `infinity` / `nan`. Decimal float values are rounded correctly by the +//! standard library once the Zig grammar has accepted the input (Zig's +//! algorithm is also correctly rounded); hex floats follow `convertHex`. + +/// `std.fmt.ParseIntError`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseIntError { + /// The result cannot fit in the type specified. + Overflow, + /// The input was empty or contained an invalid character. + InvalidCharacter, +} + +impl std::fmt::Display for ParseIntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ParseIntError::Overflow => "Overflow", + ParseIntError::InvalidCharacter => "InvalidCharacter", + }) + } +} + +impl std::error::Error for ParseIntError {} + +/// `std.fmt.ParseFloatError`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseFloatError; + +/// Integer types that can be targeted by [`parse_int`]. +pub trait ZigInt: Sized + Copy { + const MIN_I128: i128; + const MAX_I128: i128; + fn from_i128(v: i128) -> Self; +} + +macro_rules! zig_int { + ($($t:ty),*) => {$( + impl ZigInt for $t { + const MIN_I128: i128 = <$t>::MIN as i128; + const MAX_I128: i128 = <$t>::MAX as i128; + fn from_i128(v: i128) -> Self { + v as $t + } + } + )*}; +} +zig_int!(u8, u16, u32, u64, usize, i8, i16, i32, i64, isize); + +/// `std.fmt.charToDigit`. +fn char_to_digit(c: u8, base: u8) -> Result { + let value = match c { + b'0'..=b'9' => c - b'0', + b'A'..=b'Z' => c - b'A' + 10, + b'a'..=b'z' => c - b'a' + 10, + _ => return Err(ParseIntError::InvalidCharacter), + }; + if value >= base { + return Err(ParseIntError::InvalidCharacter); + } + Ok(value) +} + +/// `std.fmt.parseInt(T, buf, base)`. +pub fn parse_int(buf: &[u8], base: u8) -> Result { + match buf.first() { + None => Err(ParseIntError::InvalidCharacter), + Some(b'+') => parse_int_with_sign(&buf[1..], base, false), + Some(b'-') => parse_int_with_sign(&buf[1..], base, true), + Some(_) => parse_int_with_sign(buf, base, false), + } +} + +/// `std.fmt.parseUnsigned(T, buf, base)` (no sign accepted). +pub fn parse_unsigned(buf: &[u8], base: u8) -> Result { + parse_int_with_sign(buf, base, false) +} + +fn parse_int_with_sign( + buf: &[u8], + base: u8, + negative: bool, +) -> Result { + if buf.is_empty() { + return Err(ParseIntError::InvalidCharacter); + } + + let mut buf_base = base; + let mut buf_start = buf; + if base == 0 { + // Treat is as a decimal number by default. + buf_base = 10; + // Detect the base by looking at buf prefix. + if buf.len() > 2 && buf[0] == b'0' { + match buf[1].to_ascii_lowercase() { + b'b' => { + buf_base = 2; + buf_start = &buf[2..]; + } + b'o' => { + buf_base = 8; + buf_start = &buf[2..]; + } + b'x' => { + buf_base = 16; + buf_start = &buf[2..]; + } + _ => {} + } + } + } + + if buf_start[0] == b'_' || buf_start[buf_start.len() - 1] == b'_' { + return Err(ParseIntError::InvalidCharacter); + } + + let mut acc: i128 = 0; + for &c in buf_start { + if c == b'_' { + continue; + } + let digit = i128::from(char_to_digit(c, buf_base)?); + acc = acc + .checked_mul(i128::from(buf_base)) + .ok_or(ParseIntError::Overflow)?; + acc = if negative { + acc.checked_sub(digit) + } else { + acc.checked_add(digit) + } + .ok_or(ParseIntError::Overflow)?; + if acc < T::MIN_I128 || acc > T::MAX_I128 { + return Err(ParseIntError::Overflow); + } + } + Ok(T::from_i128(acc)) +} + +// --------------------------------------------------------------------- +// Floats + +/// Float types that can be targeted by [`parse_float`]. +pub trait ZigFloat: Sized + Copy + std::str::FromStr + std::fmt::Display { + fn is_nan_value(self) -> bool; + const MANTISSA_BITS: u32; + const FRACTIONAL_BITS: u32; + const EXP_BITS: u32; + const EXP_MAX: i64; + const EXP_MIN: i64; + const INF: Self; + const NAN: Self; + fn neg(self) -> Self; + fn zero(negative: bool) -> Self; + fn from_bits64(bits: u64) -> Self; +} + +macro_rules! zig_float { + ($t:ty, $bits:ty, $m:expr, $e:expr, $max:expr, $min:expr) => { + impl ZigFloat for $t { + const MANTISSA_BITS: u32 = $m; + const FRACTIONAL_BITS: u32 = $m; + const EXP_BITS: u32 = $e; + const EXP_MAX: i64 = $max; + const EXP_MIN: i64 = $min; + const INF: Self = <$t>::INFINITY; + const NAN: Self = <$t>::NAN; + fn is_nan_value(self) -> bool { + self.is_nan() + } + fn neg(self) -> Self { + -self + } + fn zero(negative: bool) -> Self { + if negative { -0.0 } else { 0.0 } + } + fn from_bits64(bits: u64) -> Self { + <$t>::from_bits(bits as $bits) + } + } + }; +} +zig_float!(f32, u32, 23, 8, 127, -126); +zig_float!(f64, u64, 52, 11, 1023, -1022); + +struct Number { + exponent: i64, + mantissa: u64, + many_digits: bool, + hex: bool, +} + +/// `FloatStream.zig`. +struct Stream<'a> { + slice: &'a [u8], + offset: usize, + underscore_count: usize, +} + +impl<'a> Stream<'a> { + fn new(slice: &'a [u8]) -> Self { + Stream { + slice, + offset: 0, + underscore_count: 0, + } + } + + fn offset_true(&self) -> usize { + self.offset - self.underscore_count + } + + fn reset(&mut self) { + self.offset = 0; + self.underscore_count = 0; + } + + fn first(&self) -> Option { + self.slice.get(self.offset).copied() + } + + fn first_is(&self, cs: &[u8]) -> bool { + self.first().is_some_and(|c| cs.contains(&c)) + } + + fn first_is_lower(&self, c: u8) -> bool { + self.first().is_some_and(|ok| ok | 0x20 == c) + } + + fn first_is_digit(&self, base: u8) -> bool { + self.first().is_some_and(|c| is_digit(c, base)) + } + + fn advance(&mut self, n: usize) { + self.offset += n; + } + + fn scan_digit(&mut self, base: u8) -> Option { + loop { + let ok = self.first()?; + if ok.is_ascii_digit() { + self.advance(1); + return Some(ok - b'0'); + } else if base == 16 && (b'a'..=b'f').contains(&ok) { + self.advance(1); + return Some(ok - b'a' + 10); + } else if base == 16 && (b'A'..=b'F').contains(&ok) { + self.advance(1); + return Some(ok - b'A' + 10); + } else if ok == b'_' { + self.advance(1); + self.underscore_count += 1; + } else { + return None; + } + } + } +} + +fn is_digit(c: u8, base: u8) -> bool { + if base == 10 { + c.is_ascii_digit() + } else { + c.is_ascii_hexdigit() + } +} + +fn try_parse_digits(stream: &mut Stream<'_>, x: &mut u64, base: u8) { + while let Some(digit) = stream.scan_digit(base) { + *x = x + .wrapping_mul(u64::from(base)) + .wrapping_add(u64::from(digit)); + } +} + +fn min_n_digit_int(digit_count: usize) -> u64 { + let mut n: u64 = 1; + for _ in 1..digit_count { + n = n.wrapping_mul(10); + } + n +} + +fn try_parse_n_digits(stream: &mut Stream<'_>, x: &mut u64, base: u8, n: usize) { + while *x < min_n_digit_int(n) { + if let Some(digit) = stream.scan_digit(base) { + *x = x + .wrapping_mul(u64::from(base)) + .wrapping_add(u64::from(digit)); + } else { + break; + } + } +} + +fn parse_scientific(stream: &mut Stream<'_>) -> Option { + let mut exponent: i64 = 0; + let mut negative = false; + + if let Some(c) = stream.first() { + negative = c == b'-'; + if c == b'-' || c == b'+' { + stream.advance(1); + } + } + if stream.first_is_digit(10) { + while let Some(digit) = stream.scan_digit(10) { + // no overflows here, saturate well before overflow + if exponent < 0x1000_0000 { + exponent = 10 * exponent + i64::from(digit); + } + } + return Some(if negative { -exponent } else { exponent }); + } + None +} + +fn valid_underscores(s: &[u8], base: u8) -> bool { + let mut i = 0; + while i < s.len() { + if s[i] == b'_' { + // underscore at start of end + if i == 0 || i + 1 == s.len() { + return false; + } + // consecutive underscores + if !is_digit(s[i - 1], base) || !is_digit(s[i + 1], base) { + return false; + } + // next is guaranteed a digit, skip an extra + i += 1; + } + i += 1; + } + true +} + +fn parse_partial_number_base( + stream: &mut Stream<'_>, + n: &mut usize, + base: u8, + max_mantissa_digits: usize, + exp_char_lower: u8, +) -> Option { + // parse initial digits before dot + let mut mantissa: u64 = 0; + try_parse_digits(stream, &mut mantissa, base); + let int_end = stream.offset_true(); + let mut n_digits = stream.offset_true() as isize; + + // handle dot with the following digits + let mut exponent: i64 = 0; + if stream.first_is(b".") { + stream.advance(1); + let marker = stream.offset_true(); + try_parse_digits(stream, &mut mantissa, base); + let n_after_dot = stream.offset_true() - marker; + exponent = -(n_after_dot as i64); + n_digits += n_after_dot as isize; + } + + // adjust required shift to offset mantissa for base-16 (2^4) + if base == 16 { + exponent *= 4; + } + + if n_digits == 0 { + return None; + } + + // handle scientific format + let mut exp_number: i64 = 0; + if stream.first_is_lower(exp_char_lower) { + stream.advance(1); + exp_number = parse_scientific(stream)?; + exponent += exp_number; + } + + // length must be complete parsed length + *n += stream.offset; + + if stream.underscore_count > 0 && !valid_underscores(stream.slice, base) { + return None; + } + + // common case with not many digits + if n_digits <= max_mantissa_digits as isize { + return Some(Number { + exponent, + mantissa, + many_digits: false, + hex: base == 16, + }); + } + + n_digits -= max_mantissa_digits as isize; + let mut many_digits = false; + stream.reset(); // re-parse from beginning + while stream.first_is(b"0._") { + let next = stream.first().unwrap_or(0); + if next == b'_' { + stream.underscore_count += 1; + } else { + n_digits -= isize::from(next.saturating_sub(b'0' - 1)); + } + stream.advance(1); + } + if n_digits > 0 { + // at this point we have more than max_mantissa_digits significant digits + many_digits = true; + mantissa = 0; + stream.reset(); + try_parse_n_digits(stream, &mut mantissa, base, max_mantissa_digits); + + exponent = if mantissa >= min_n_digit_int(max_mantissa_digits) { + // big int + int_end as i64 - stream.offset_true() as i64 + } else { + // the next byte must be present and be '.' + stream.advance(1); + let marker = stream.offset_true(); + try_parse_n_digits(stream, &mut mantissa, base, max_mantissa_digits); + marker as i64 - stream.offset_true() as i64 + }; + if base == 16 { + exponent *= 4; + } + // add back the explicit part + exponent += exp_number; + } + + Some(Number { + exponent, + mantissa, + many_digits, + hex: base == 16, + }) +} + +/// Returns the parsed hex-float `Number`, or a validated decimal slice. +enum Parsed { + Hex(Number), + Decimal, +} + +fn parse_number(s: &[u8]) -> Option { + let mut consumed: usize = 0; + let parsed = if s.len() >= 2 && s[0] == b'0' && s[1].eq_ignore_ascii_case(&b'x') { + let mut stream = Stream::new(&s[2..]); + consumed += 2; + parse_partial_number_base(&mut stream, &mut consumed, 16, 16, b'p').map(Parsed::Hex) + } else { + let mut stream = Stream::new(s); + parse_partial_number_base(&mut stream, &mut consumed, 10, 19, b'e').map(|_| Parsed::Decimal) + }; + // must consume entire float (no trailing data) + if s.len() == consumed { parsed } else { None } +} + +fn parse_inf_or_nan(s: &[u8], negative: bool) -> Option { + let starts = |s: &[u8], p: &[u8]| s.len() >= p.len() && s[..p.len()].eq_ignore_ascii_case(p); + if starts(s, b"inf") { + let consumed = if starts(&s[3..], b"inity") { 8 } else { 3 }; + if s.len() != consumed { + return None; + } + return Some(if negative { T::INF.neg() } else { T::INF }); + } + if starts(s, b"nan") { + return (s.len() == 3).then_some(T::NAN); + } + None +} + +/// `convert_hex.zig`. +fn convert_hex(n_: &Number, negative: bool) -> T { + let mut mantissa = n_.mantissa; + let mut exponent = n_.exponent; + + if mantissa == 0 { + return T::zero(negative); + } + + let max_exp = T::EXP_MAX; + let min_exp = T::EXP_MIN; + let mantissa_bits = T::MANTISSA_BITS; + let fractional_bits = T::FRACTIONAL_BITS; + let exp_bits = T::EXP_BITS; + let exp_bias = min_exp - 1; + + // mantissa now implicitly divided by 2^fractional_bits + exponent += i64::from(fractional_bits); + + while mantissa != 0 && mantissa >> (mantissa_bits + 2) == 0 { + mantissa <<= 1; + exponent -= 1; + } + if n_.many_digits { + mantissa |= 1; + } + while mantissa >> (1 + fractional_bits + 2) != 0 { + mantissa = (mantissa >> 1) | (mantissa & 1); + exponent += 1; + } + + // If exponent is too negative, denormalize in hopes of making it + // representable. (The -2 is for the rounding bits.) + while mantissa > 1 && exponent < min_exp - 2 { + mantissa = (mantissa >> 1) | (mantissa & 1); + exponent += 1; + } + + // Round using two bottom bits. + let mut round = mantissa & 3; + mantissa >>= 2; + round |= mantissa & 1; // round to even (round up if mantissa is odd) + exponent += 2; + if round == 3 { + mantissa += 1; + if mantissa == 1 << (1 + fractional_bits) { + mantissa >>= 1; + exponent += 1; + } + } + + // Denormal or zero + if mantissa >> fractional_bits == 0 { + exponent = exp_bias; + } + + // Infinity and range error + if exponent > max_exp { + return if negative { T::INF.neg() } else { T::INF }; + } + + let mut bits = mantissa & ((1u64 << mantissa_bits) - 1); + bits |= (((exponent - exp_bias) as u64) & ((1u64 << exp_bits) - 1)) << mantissa_bits; + if negative { + bits |= 1u64 << (mantissa_bits + exp_bits); + } + T::from_bits64(bits) +} + +/// Zig's `{d}` for floats: like Rust's `Display` except NaN is `nan` +/// (infinities are `inf` / `-inf` in both). +#[must_use] +pub fn format_float_d(v: T) -> String { + if v.is_nan_value() { + "nan".to_string() + } else { + v.to_string() + } +} + +/// `std.fmt.parseFloat(T, s)`. +pub fn parse_float(s: &[u8]) -> Result { + if s.is_empty() { + return Err(ParseFloatError); + } + + let mut i = 0; + let negative = s[i] == b'-'; + if s[i] == b'-' || s[i] == b'+' { + i += 1; + } + if s.len() == i { + return Err(ParseFloatError); + } + let s = &s[i..]; + + match parse_number(s) { + None => parse_inf_or_nan::(s, negative).ok_or(ParseFloatError), + Some(Parsed::Hex(n)) => { + debug_assert!(n.hex); + Ok(convert_hex::(&n, negative)) + } + Some(Parsed::Decimal) => { + // The grammar is validated; underscores are ignorable now. + let cleaned: String = s + .iter() + .filter(|&&c| c != b'_') + .map(|&c| char::from(c)) + .collect(); + let v: T = cleaned.parse().map_err(|_| ParseFloatError)?; + Ok(if negative { v.neg() } else { v }) + } + } +} + +#[cfg(test)] +#[allow(clippy::approx_constant)] +mod tests { + use super::*; + + fn pi(s: &str, base: u8) -> Result { + parse_int::(s.as_bytes(), base) + } + + #[test] + fn parse_int_zig_std_test() { + assert_eq!(pi::("-10", 10), Ok(-10)); + assert_eq!(pi::("+10", 10), Ok(10)); + assert_eq!(pi::("+10", 10), Ok(10)); + assert_eq!(pi::("-10", 10), Err(ParseIntError::Overflow)); + for bad in [" 10", "10 ", "_10_", "0x_10_", "0x10_", "0x_10"] { + assert_eq!(pi::(bad, 10), Err(ParseIntError::InvalidCharacter)); + } + assert_eq!(pi::("255", 10), Ok(255)); + assert_eq!(pi::("256", 10), Err(ParseIntError::Overflow)); + + // +0 and -0 should work for unsigned + assert_eq!(pi::("-0", 10), Ok(0)); + assert_eq!(pi::("+0", 10), Ok(0)); + + assert_eq!(pi::("-128", 10), Ok(-128)); + assert_eq!(pi::("-4398046511104", 10), Ok(-4_398_046_511_104)); + + // empty string or bare +- is invalid + for bad in ["", "+", "-"] { + assert_eq!(pi::(bad, 10), Err(ParseIntError::InvalidCharacter)); + assert_eq!(pi::(bad, 10), Err(ParseIntError::InvalidCharacter)); + } + + // autodectect the base + assert_eq!(pi::("111", 0), Ok(111)); + assert_eq!(pi::("1_1_1", 0), Ok(111)); + assert_eq!(pi::("+0b111", 0), Ok(7)); + assert_eq!(pi::("+0B111", 0), Ok(7)); + assert_eq!(pi::("+0b1_11", 0), Ok(7)); + assert_eq!(pi::("+0o111", 0), Ok(73)); + assert_eq!(pi::("+0O111", 0), Ok(73)); + assert_eq!(pi::("+0o11_1", 0), Ok(73)); + assert_eq!(pi::("+0x111", 0), Ok(273)); + assert_eq!(pi::("-0b111", 0), Ok(-7)); + assert_eq!(pi::("-0b11_1", 0), Ok(-7)); + assert_eq!(pi::("-0o111", 0), Ok(-73)); + assert_eq!(pi::("-0x111", 0), Ok(-273)); + assert_eq!(pi::("-0X111", 0), Ok(-273)); + assert_eq!(pi::("-0x1_11", 0), Ok(-273)); + + // bare binary/octal/decimal prefix is invalid + for bad in ["0b", "0o", "0x"] { + assert_eq!(pi::(bad, 0), Err(ParseIntError::InvalidCharacter)); + } + + assert_eq!(pi::("-10", 2), Ok(-2)); + assert_eq!(pi::("-10", 8), Ok(-8)); + assert_eq!(pi::("-10", 16), Ok(-16)); + } + + #[test] + fn parse_unsigned_zig_std_test() { + let pu = |s: &str, b| parse_unsigned::(s.as_bytes(), b); + assert_eq!(parse_unsigned::(b"050124", 10), Ok(50124)); + assert_eq!(parse_unsigned::(b"65535", 10), Ok(65535)); + assert_eq!(parse_unsigned::(b"65_535", 10), Ok(65535)); + assert_eq!( + parse_unsigned::(b"65536", 10), + Err(ParseIntError::Overflow) + ); + assert_eq!(pu("0ffffffffffffffff", 16), Ok(u64::MAX)); + assert_eq!(pu("0f_fff_fff_fff_fff_fff", 16), Ok(u64::MAX)); + assert_eq!(pu("10000000000000000", 16), Err(ParseIntError::Overflow)); + assert_eq!(parse_unsigned::(b"DeadBeef", 16), Ok(0xDEAD_BEEF)); + assert_eq!( + parse_unsigned::(b"f", 10), + Err(ParseIntError::InvalidCharacter) + ); + assert_eq!( + parse_unsigned::(b"109", 8), + Err(ParseIntError::InvalidCharacter) + ); + assert_eq!(parse_unsigned::(b"NUMBER", 36), Ok(1_442_151_747)); + // parseUnsigned does not expect a sign + assert_eq!( + parse_unsigned::(b"+0", 10), + Err(ParseIntError::InvalidCharacter) + ); + assert_eq!( + parse_unsigned::(b"-0", 10), + Err(ParseIntError::InvalidCharacter) + ); + assert_eq!( + parse_unsigned::(b"", 10), + Err(ParseIntError::InvalidCharacter) + ); + } + + #[test] + fn parse_int_non_ascii_and_prefix_edge_cases() { + // `buf.len > 2` gates prefix detection: "0x" alone is decimal "0x". + assert_eq!(pi::("0x1", 0), Ok(1)); + assert_eq!(pi::("0_1", 0), Ok(1)); + assert_eq!(pi::("1__0", 0), Ok(10)); + assert_eq!(pi::("0X10", 0), Ok(16)); + assert_eq!( + parse_int::("é1".as_bytes(), 0), + Err(ParseIntError::InvalidCharacter) + ); + // Prefixes are only honoured with base 0. + assert_eq!(pi::("0x10", 10), Err(ParseIntError::InvalidCharacter)); + assert_eq!(pi::("0b11", 16), Ok(0xb11)); + } + + fn pf64(s: &str) -> Result { + parse_float::(s.as_bytes()) + } + fn pf32(s: &str) -> Result { + parse_float::(s.as_bytes()) + } + + fn approx(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-7 + } + + #[test] + fn parse_float_zig_std_test() { + for bad in ["", " 1", "1abc", "+", "-"] { + assert!(pf64(bad).is_err(), "{bad:?}"); + assert!(pf32(bad).is_err(), "{bad:?}"); + } + assert_eq!(pf64("0"), Ok(0.0)); + assert_eq!(pf64("+0"), Ok(0.0)); + assert_eq!(pf64("-0"), Ok(0.0)); + assert!(pf64("-0").unwrap().is_sign_negative()); + assert_eq!(pf64("0e0"), Ok(0.0)); + assert_eq!(pf64("2e3"), Ok(2000.0)); + assert_eq!(pf64("1e0"), Ok(1.0)); + assert_eq!(pf64("-2e3"), Ok(-2000.0)); + assert_eq!(pf64("-1e0"), Ok(-1.0)); + assert_eq!(pf64("1.234e3"), Ok(1234.0)); + assert!(approx(pf64("3.141").unwrap(), 3.141)); + assert!(approx(pf64("-3.141").unwrap(), -3.141)); + assert_eq!(pf64("1e-5000"), Ok(0.0)); + assert_eq!(pf64("1e+5000"), Ok(f64::INFINITY)); + assert_eq!( + pf64("0.4e0066999999999999999999999999999999999999999999999999999"), + Ok(f64::INFINITY) + ); + assert!(approx( + pf64("0_1_2_3_4_5_6.7_8_9_0_0_0e0_0_1_0").unwrap() / 1e10, + 123_456.789 + )); + // underscore rule: only between two digits, never repeated. + for bad in [ + "0123456.789000e_0010", + "_0123456.789000e0010", + "0__123456.789000e_0010", + "0123456_.789000e0010", + "0123456.789000e0010_", + ] { + assert!(pf64(bad).is_err(), "{bad:?}"); + } + assert!(approx(pf64("1e-2").unwrap(), 0.01)); + assert!(approx(pf64("1234e-2").unwrap(), 12.34)); + assert!(approx(pf64("1.").unwrap(), 1.0)); + assert!(approx(pf64("0.").unwrap(), 0.0)); + assert!(approx(pf64(".1").unwrap(), 0.1)); + assert!(approx(pf64(".0").unwrap(), 0.0)); + assert!(approx(pf64(".1e-1").unwrap(), 0.01)); + for bad in [".", ".e1", "0.e"] { + assert!(pf64(bad).is_err(), "{bad:?}"); + } + assert!(approx(pf64("123142.1").unwrap(), 123_142.1)); + assert!(approx(pf64("-123142.1124").unwrap(), -123_142.112_4)); + assert!(approx( + pf64("2.71828182845904523536").unwrap(), + 2.718_281_828_459_045 + )); + } + + #[test] + fn parse_float_nan_and_inf() { + assert!(pf64("nAn").unwrap().is_nan()); + assert_eq!(pf64("inF"), Ok(f64::INFINITY)); + assert_eq!(pf64("-INF"), Ok(f64::NEG_INFINITY)); + assert_eq!(pf64("+Inf"), Ok(f64::INFINITY)); + assert_eq!(pf32("-iNf"), Ok(f32::NEG_INFINITY)); + assert_eq!(pf64("infinity"), Ok(f64::INFINITY)); + assert_eq!(pf64("-InFiNiTy"), Ok(f64::NEG_INFINITY)); + assert!(pf64("-nan").unwrap().is_nan()); + for bad in ["infx", "infinit", "nanx", "in", "na", "infinityy", "i_nf"] { + assert!(pf64(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn format_float_d_matches_zig() { + assert_eq!(format_float_d(f64::NAN), "nan"); + assert_eq!(format_float_d(-f32::NAN), "nan"); + assert_eq!(format_float_d(f64::INFINITY), "inf"); + assert_eq!(format_float_d(f64::NEG_INFINITY), "-inf"); + assert_eq!(format_float_d(13.5f32), "13.5"); + assert_eq!(format_float_d(-0.0f64), "-0"); + } + + #[test] + fn parse_float_largest_normals() { + assert_eq!(pf32("3.4028234664E38").unwrap().to_bits(), 0x7f7f_ffff); + assert_eq!( + pf64("1.7976931348623157E308").unwrap().to_bits(), + 0x7fef_ffff_ffff_ffff + ); + } + + #[test] + fn parse_float_hex_special_and_zero() { + assert_eq!(pf32("0x9999p9999"), Ok(f32::INFINITY)); + assert_eq!(pf32("-0x9999p9999"), Ok(f32::NEG_INFINITY)); + assert_eq!(pf32("0x0"), Ok(0.0)); + assert_eq!(pf32("-0x0"), Ok(0.0)); + assert_eq!(pf32("0x0p42"), Ok(0.0)); + assert_eq!(pf32("-0x0.00000p42"), Ok(0.0)); + assert_eq!(pf32("0x0.00000p666"), Ok(0.0)); + } + + #[test] + fn parse_float_hex_f32() { + assert!(pf32("0x").is_err()); + assert_eq!(pf32("0x1p0"), Ok(1.0)); + assert_eq!(pf32("-0x1p-1"), Ok(-0.5)); + assert_eq!(pf32("0x10p+10"), Ok(16384.0)); + assert_eq!(pf32("0x10p-10"), Ok(0.015_625)); + assert_eq!(pf32("0x0.ffffffp128"), Ok(hexf(0xff_ffff, 128 - 24))); + assert_eq!(pf32("0x1.fffffeP+127"), Ok(f32::MAX)); + assert_eq!(pf32("-0x1.fffffeP+127"), Ok(-f32::MAX)); + assert_eq!(pf32("0x1p-126"), Ok(f32::MIN_POSITIVE)); + assert_eq!(pf32("-0x1p-126"), Ok(-f32::MIN_POSITIVE)); + assert_eq!(pf32("0x1P-149"), Ok(f32::from_bits(1))); + assert_eq!(pf32("-0x1P-149"), Ok(-f32::from_bits(1))); + } + + fn hexf(m: u32, e: i32) -> f32 { + m as f32 * 2f32.powi(e) + } + + #[test] + fn parse_float_hex_f64() { + assert_eq!(pf64("0x1p0"), Ok(1.0)); + assert_eq!(pf64("-0x1p-1"), Ok(-0.5)); + assert_eq!(pf64("0x10p+10"), Ok(16384.0)); + assert_eq!(pf64("0x10p-10"), Ok(0.015_625)); + assert_eq!(pf64("0x1.fffffffffffffp+1023"), Ok(f64::MAX)); + assert_eq!(pf64("-0x1.fffffffffffffp1023"), Ok(-f64::MAX)); + assert_eq!(pf64("0x1p-1022"), Ok(f64::MIN_POSITIVE)); + assert_eq!(pf64("-0x1p-1022"), Ok(-f64::MIN_POSITIVE)); + assert_eq!(pf64("0x1p-1074"), Ok(f64::from_bits(1))); + assert_eq!(pf64("-0x1p-1074"), Ok(-f64::from_bits(1))); + } + + #[test] + fn parse_float_hex_with_underscores_and_many_digits() { + assert_eq!(pf64("0x1.cp+3"), Ok(14.0)); + assert_eq!(pf32("0x1.cp+3"), Ok(14.0)); + assert_eq!(pf64("0X1_0"), Ok(16.0)); + assert_eq!(pf64("0x1_0p1_0"), Ok(16.0 * 1024.0)); + assert!(pf64("0x_1").is_err()); + assert!(pf64("0x1_").is_err()); + assert!(pf64("0x1p_1").is_err()); + assert!(pf64("0x1__0").is_err()); + // many_digits: result must match between f32 and a direct f32 round. + let a = pf32("0xffffffffffffffff.0p0").unwrap(); + assert_eq!(a, 1.844_674_4e19_f32); + // Decimal numbers with underscores. + assert_eq!(pf32("1_4"), Ok(14.0)); + assert!(pf32("1__4").is_err()); + } +} diff --git a/crates/ghostty-renderer/src/generic.rs b/crates/ghostty-renderer/src/generic.rs index 45357a2..97c410e 100644 --- a/crates/ghostty-renderer/src/generic.rs +++ b/crates/ghostty-renderer/src/generic.rs @@ -244,7 +244,7 @@ impl DerivedConfig { .value .iter() .filter_map(|p| { - let path = match p.path().to_path() { + let path = match p.path().to_syscall_path() { Ok(path) => path, Err(error) => { log::warn!("invalid shader path: {error}"); @@ -869,7 +869,7 @@ impl DrawState { if let Some(p) = &self.config.bg_image && let Some(image) = p .path() - .to_path() + .to_syscall_path() .map_err(|error| log::warn!("invalid background image path: {error}")) .ok() .and_then(|path| load_background_image(&path)) diff --git a/crates/ghostty-termio/src/shell_integration.rs b/crates/ghostty-termio/src/shell_integration.rs index 2b9ab7c..90b39c8 100644 --- a/crates/ghostty-termio/src/shell_integration.rs +++ b/crates/ghostty-termio/src/shell_integration.rs @@ -532,6 +532,17 @@ mod tests { assert_eq!(detect_shell(&sh("")), None); } + #[test] + fn detect_shell_splits_on_all_zig_separators() { + for sep in ["\n", "\r", "\t", " "] { + let v: Vec<_> = sh(&format!("bash{sep}-i")).arg_iterator().collect(); + assert_eq!(v, ["bash", "-i"]); + assert_eq!(detect_shell(&sh(&format!("zsh{sep}-l"))), Some(Shell::Zsh)); + } + // The first argument must end at the LF, not include `-i`. + assert_eq!(detect_shell(&sh("/bin/fish\n-l")), Some(Shell::Fish)); + } + #[test] fn arg_iterator_shell_and_direct() { let v: Vec<_> = sh("echo hello world").arg_iterator().collect(); diff --git a/docs/rewrite/notes/final-Z11.md b/docs/rewrite/notes/final-Z11.md new file mode 100644 index 0000000..dda0ec9 --- /dev/null +++ b/docs/rewrite/notes/final-Z11.md @@ -0,0 +1,72 @@ +# Z11: RV9 gap groups (Ghostty 1.3.1 parity) + +Fixes for the six gap groups in `review-rv9-codex.md`. No version change, no commit. + +## Fixes + +1. **Interior NUL in font names.** `fontconfig::Pattern::add_string_bytes` + ends the value at the first NUL (the C-string handoff Zig does) instead of + failing, so `font-family`, `font-style` and codepoint-map families no longer + hit the `assert!` in `discovery::to_fc_pattern`. + Test: `discovery::tests::interior_nul_in_font_names_ends_the_c_string`. +2. **Numeric grammars.** New `ghostty_os::zig_fmt`: exact ports of + `std.fmt.parseInt`/`parseUnsigned` (underscores, `0b/0o/0x` in both cases, + sign rules, `len > 2` prefix gate, Overflow vs InvalidCharacter) and + `std.fmt.parseFloat` (grammar of `parse.zig` incl. `validUnderscores`, hex + floats via a port of `convertHex` incl. the many-digits path, `inf`/ + `infinity`/`nan`). Decimal values are rounded by Rust's correctly rounded + `FromStr` once the Zig grammar accepted the text. Ported all std tests. + Used by: primitive int/float fields (base 0), palette key (u8, base 0, Zig + error names), variation values, mouse-scroll bare float, quick-terminal + `px`/`%`, window-padding (u32, base 10), duration (`parseUnsigned` u64), + background-blur radius, codepoint/clipboard map hex (base 16), metric + modifiers, keybind numeric parameters (base 10 / float), ssh-cache entry + timestamp, `GIO_LAUNCHED_DESKTOP_FILE_PID`. + Also found by differential runs: Zig `{d}` prints `nan` (Rust `NaN`); + finalize clamps use Zig `@min/@max/clamp` NaN and +-0 semantics + (`cli_args::zig_min/zig_max/zig_clamp`); a failed `background-blur` radius + parse leaves `radius` (0 or the previous radius) like Zig's in-place union + initialisation. +3. **C strings.** `config_capi` getters, C action titles/strings + (`action_c::Keep`) and the embedded clipboard write callback now end the C + string at the first NUL instead of deleting NUL bytes. +4. **GTK new-window args.** `--working-directory=`/`--title=` trim only + `std.ascii.whitespace` (space, \t, \n, \r, VT, FF); U+00A0 etc. survive. +5. **Shell tokenizer.** New `ghostty_os::arg_iterator::ArgIteratorGeneral` + (port of `std.process.ArgIteratorGeneral`, including `comments`/ + `single_quotes` options, `skip`, NUL terminates). `Command::arg_iterator` + (and so shell-integration detection) use it: space, tab, CR and LF split. + Ported the std "response file arg parsing" test plus separator/quote/NUL + tests; termio test `detect_shell_splits_on_all_zig_separators`. +6. **Parser diagnostics.** UTF-8 validation moved to where Zig would notice it: + metric modifiers (`InvalidFormat`), quick-terminal size (`MissingUnit`/ + `InvalidValue`), `link` (`NotImplemented`), colors/palette + (`InvalidCharacter`), background-blur, keybind trigger (parsed after the + action, like `Parser.init`) and action parameters (known action with bad + bytes is `InvalidFormat`, unknown name `InvalidAction`). + Other differences found: config-file open errors are printed as + `error.FileNotFound` etc.; paths with interior NUL resolve/open as the C + string prefix (`EncodedOs::to_syscall_path`) for relative path expansion, + `config-file`, custom shaders, background image, gtk css. + +## Differential harness + +Script (not committed): for each of the 202 `Config.zig` fields x ~220 hostile +and valid values, run `/usr/bin/ghostty +validate-config --config-file=F` and +`+show-config` (value in `$XDG_CONFIG_HOME/ghostty/config`) against the Rust +binary and compare exit code, stdout and stderr. Final state: zero mismatches +except `theme` (resources path differs by install location), +`auto-update-channel` default (`tip` vs `stable`, build channel) and +`gtk-opengl-debug` (debug build). + +Regression tests: `crates/ghostty-config/tests/zig_parity.rs`, +`ghostty_os::zig_fmt::tests`, `ghostty_os::arg_iterator::tests`, +`ghostty-config` command tests, lib/gtk tests listed above. + +## Not covered / notes + +- `ghostty-terminal` OSC/tmux/kitty parsers also call `parseInt` + (`terminal/osc/parsers/*.zig`, `tmux/*.zig`, `kitty/*.zig`); they are outside + the config/input scope of this task and still use Rust `str::parse`/ + `from_str_radix`. `zig_fmt` is available for a follow-up. +- Windows/macOS behaviour is covered by cross-checks only.