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

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

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

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

No files matched your search

@@ -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 "), "");
}
}
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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::<i64>() {
let timestamp = match ghostty_os::zig_fmt::parse_int::<i64>(timestamp_str.as_bytes(), 10) {
Ok(t) => t,
Err(err) => {
log::warn!("Invalid timestamp in cache entry: {timestamp_str} err={err}");
+80 -48
View File
@@ -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<bool, ArgError> {
match v {
"1" | "t" | "T" | "true" => Ok(true),
@@ -514,42 +550,6 @@ pub fn parse_bool(v: &str) -> Result<bool, ArgError> {
}
}
/// 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<i128> {
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]
+14 -48
View File
@@ -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<EncodedOs> {
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<u8> = 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<EncodedOs> = 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()]);
+37 -11
View File
@@ -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(),
}
}
+15 -9
View File
@@ -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<MetricModifier, ArgError> {
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<MetricModifier, ArgError> {
if input.is_empty() {
return Err(ArgError::other("InvalidFormat"));
}
if let Some(num) = input.strip_suffix('%') {
let mut percent = num
.parse::<f64>()
if let Some(num) = input.strip_suffix(b"%") {
let mut percent = ghostty_os::zig_fmt::parse_float::<f64>(num)
.map_err(|_| ArgError::other("InvalidFormat"))?;
percent /= 100.0;
@@ -46,15 +51,14 @@ impl MetricModifier {
return Ok(MetricModifier::Percent(1.0 + percent));
}
input
.parse::<i32>()
ghostty_os::zig_fmt::parse_int::<i32>(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<MetricModifier, ArgError> {
Self::parse(input.ok_or(ArgError::ValueRequired)?)
pub fn parse_cli_value(input: Option<&[u8]>) -> Result<MetricModifier, ArgError> {
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)
+26 -1
View File
@@ -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() {
+56 -34
View File
@@ -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, ArgError> {
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<Color, ArgError> {
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, ArgError> {
Color::from_hex_bytes(input.as_bytes())
}
/// [`Color::from_hex`] over bytes.
pub fn from_hex_bytes(input: &[u8]) -> Result<Color, ArgError> {
// 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<u8, ArgError> {
(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, ArgError> {
TerminalColor::from_cli_bytes(input.map(str::as_bytes))
}
/// [`TerminalColor::from_cli`] over bytes.
pub fn from_cli_bytes(input: Option<&[u8]>) -> Result<TerminalColor, ArgError> {
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, ArgError> {
BoldColor::from_cli_bytes(input.map(str::as_bytes))
}
/// [`BoldColor::from_cli`] over bytes.
pub fn from_cli_bytes(input: Option<&[u8]>) -> Result<BoldColor, ArgError> {
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::<u8>(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::<f64>().map_err(|_| ArgError::InvalidValue)?,
value: ghostty_os::zig_fmt::parse_float::<f64>(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<u32> {
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::<u32>(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"))
}
+42 -27
View File
@@ -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::<f64>(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<Self, ArgError> {
pub fn parse(input: &[u8]) -> Result<Self, ArgError> {
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::<u32>().map_err(|_| ArgError::InvalidValue)?,
ghostty_os::zig_fmt::parse_int::<u32>(num, 10)
.map_err(|_| ArgError::InvalidValue)?,
));
}
if let Some(num) = input.strip_suffix('%') {
let percentage = num.parse::<f32>().map_err(|_| ArgError::InvalidValue)?;
if let Some(num) = input.strip_suffix(b"%") {
let percentage =
ghostty_os::zig_fmt::parse_float::<f32>(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::<u8>(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<u64> {
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::<u64>(s, 10).ok()
}
impl Duration {
@@ -1201,7 +1213,10 @@ pub struct WindowPadding {
impl WindowPadding {
pub fn parse(input: Option<&str>) -> Result<WindowPadding, ArgError> {
let input = input.ok_or(ArgError::ValueRequired)?;
let p = |s: &str| s.parse::<u32>().map_err(|_| ArgError::InvalidValue);
let p = |s: &str| {
ghostty_os::zig_fmt::parse_int::<u32>(s.as_bytes(), 10)
.map_err(|_| ArgError::InvalidValue)
};
if let Some(idx) = input.find(',') {
let left = p(trim_ws(&input[..idx]))?;
+147
View File
@@ -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<Config, ArgError> {
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());
}
+37
View File
@@ -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() {
+3 -1
View File
@@ -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;
};
+22 -15
View File
@@ -177,10 +177,8 @@ impl SplitResizeParameter {
pub(super) fn parse_param(param: &str) -> Result<SplitResizeParameter, Error> {
let mut it = param.split(',');
let dir = SplitResizeDirection::parse_param(it.next().ok_or(Error::InvalidFormat)?)?;
let amt = it
.next()
.ok_or(Error::InvalidFormat)?
.parse::<u16>()
let amt = it.next().ok_or(Error::InvalidFormat)?;
let amt = ghostty_os::zig_fmt::parse_int::<u16>(amt.as_bytes(), 10)
.map_err(|_| Error::InvalidFormat)?;
// If we have extra parameters it is an error
if it.next().is_some() {
@@ -304,16 +302,23 @@ pub(super) trait ParseNum: Sized {
fn parse_param(s: &str) -> Result<Self, Error>;
}
macro_rules! num_param {
macro_rules! int_param {
($($t:ty),*) => {$(
impl ParseNum for $t {
fn parse_param(s: &str) -> Result<$t, Error> {
s.parse::<$t>().map_err(|_| Error::InvalidFormat)
ghostty_os::zig_fmt::parse_int::<$t>(s.as_bytes(), 10)
.map_err(|_| Error::InvalidFormat)
}
}
)*};
}
num_param!(f32, usize, isize, i16);
int_param!(usize, isize, i16);
impl ParseNum for f32 {
fn parse_param(s: &str) -> Result<f32, Error> {
ghostty_os::zig_fmt::parse_float::<f32>(s.as_bytes()).map_err(|_| Error::InvalidFormat)
}
}
/// Escape in the style of Zig's `std.zig.stringEscape`.
pub(super) fn string_escape(s: &[u8], out: &mut String) {
@@ -378,12 +383,14 @@ impl Action {
"set_tab_title" => Ok(Self::SetTabTitle(bytes()?)),
"activate_key_table" => Ok(Self::ActivateKeyTable(bytes()?)),
"activate_key_table_once" => Ok(Self::ActivateKeyTableOnce(bytes()?)),
// Zig never validates UTF-8 for enum/numeric parameters: the
// bytes just fail the field's own grammar (`InvalidFormat`) for
// a known action and `InvalidAction` for an unknown name. U+FFFD
// is accepted by none of those grammars, so it stands in for any
// invalid parameter.
_ => Self::parse_named(
name,
param
.map(std::str::from_utf8)
.transpose()
.map_err(|_| Error::InvalidAction)?,
param.map(|p| std::str::from_utf8(p).unwrap_or("\u{fffd}")),
),
}
}
@@ -1930,15 +1937,15 @@ impl Action {
}
Action::IncreaseFontSize(v) => {
out.push(':');
out.push_str(&v.to_string());
out.push_str(&ghostty_os::zig_fmt::format_float_d(*v));
}
Action::DecreaseFontSize(v) => {
out.push(':');
out.push_str(&v.to_string());
out.push_str(&ghostty_os::zig_fmt::format_float_d(*v));
}
Action::SetFontSize(v) => {
out.push(':');
out.push_str(&v.to_string());
out.push_str(&ghostty_os::zig_fmt::format_float_d(*v));
}
Action::Search(v) => {
out.push(':');
@@ -1954,7 +1961,7 @@ impl Action {
}
Action::ScrollPageFractional(v) => {
out.push(':');
out.push_str(&v.to_string());
out.push_str(&ghostty_os::zig_fmt::format_float_d(*v));
}
Action::ScrollPageLines(v) => {
out.push(':');
+20 -12
View File
@@ -173,11 +173,7 @@ impl<'a> Parser<'a> {
// This is kind of hacky but we put a dummy trigger
// for chained inputs. The `next` will never yield this
// because we have chain set.
input: if chain {
"a"
} else {
std::str::from_utf8(&input[..eql_idx]).map_err(|_| Error::InvalidFormat)?
},
input: if chain { b"a" } else { &input[..eql_idx] },
i: 0,
},
action: Action::parse_bytes(&input[eql_idx + 1..])?,
@@ -279,13 +275,16 @@ pub struct SequenceIterator<'a> {
/// The input of triggers. This is expected to be ONLY triggers. Things
/// like the "unconsumed:" prefix or action must be stripped before
/// passing to this iterator.
pub input: &'a str,
pub input: &'a [u8],
pub i: usize,
}
impl<'a> SequenceIterator<'a> {
pub fn new(input: &'a str) -> Self {
SequenceIterator { input, i: 0 }
SequenceIterator {
input: input.as_bytes(),
i: 0,
}
}
/// Returns the next trigger in the sequence if there is no parsing error.
@@ -294,9 +293,9 @@ impl<'a> SequenceIterator<'a> {
return Ok(None);
}
let rem = &self.input[self.i..];
let idx = rem.find('>').unwrap_or(rem.len());
let idx = rem.iter().position(|b| *b == b'>').unwrap_or(rem.len());
self.i += idx + 1;
Ok(Some(Trigger::parse(&rem[..idx])?))
Ok(Some(Trigger::parse_bytes(&rem[..idx])?))
}
/// Returns true if there are no more triggers to parse.
@@ -545,19 +544,28 @@ impl Trigger {
/// not be part of a sequence (i.e. `a>b`). This parses exactly a single
/// trigger.
pub fn parse(input: &str) -> Result<Trigger, Error> {
Trigger::parse_bytes(input.as_bytes())
}
/// [`Trigger::parse`] over the original bytes. Invalid UTF-8 can only
/// reach (and fail) the key lookups, which all end in `InvalidFormat`.
pub fn parse_bytes(input: &[u8]) -> Result<Trigger, Error> {
if input.is_empty() {
return Err(Error::InvalidFormat);
}
let mut result = Trigger::default();
let mut rem: &str = input;
let mut rem: &[u8] = input;
'outer: while !rem.is_empty() {
let idx = rem.find('+').unwrap_or(rem.len());
let idx = rem.iter().position(|b| *b == b'+').unwrap_or(rem.len());
let part = &rem[..idx];
rem = if idx >= rem.len() {
""
b""
} else {
&rem[idx + 1..]
};
let Ok(part) = std::str::from_utf8(part) else {
return Err(Error::InvalidFormat);
};
// Check if its a modifier
{
+23 -4
View File
@@ -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;
+27 -5
View File
@@ -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<T: Copy>(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.
+9 -3
View File
@@ -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::<Vec<u8>>())
.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;
+254
View File
@@ -0,0 +1,254 @@
//! Port of Zig 0.16 `std.process.ArgIteratorGeneral` (`process/Args.zig`).
//!
//! Splits a command line on space, tab, CR and LF. Double quotes group;
//! backslashes are only special before a double quote (or a single quote
//! when `single_quotes` is enabled). A NUL byte terminates the command line,
//! exactly like Zig's sentinel handling.
/// `ArgIteratorGeneral` options.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Options {
/// Treat `#` at a token start as a comment up to end of line.
pub comments: bool,
/// Treat `'` as a quote character.
pub single_quotes: bool,
}
/// Iterator over the arguments of a command line.
#[derive(Debug, Clone)]
pub struct ArgIteratorGeneral<'a> {
cmd_line: &'a [u8],
index: usize,
options: Options,
}
impl<'a> ArgIteratorGeneral<'a> {
#[must_use]
pub fn new(options: Options, cmd_line: &'a [u8]) -> Self {
ArgIteratorGeneral {
cmd_line,
index: 0,
options,
}
}
/// The byte at the cursor, or 0 at the end (the sentinel).
fn character(&self) -> u8 {
self.cmd_line.get(self.index).copied().unwrap_or(0)
}
/// Skips whitespace (and comments). Returns false at the terminator.
fn skip_whitespace(&mut self) -> bool {
loop {
match self.character() {
0 => return false,
b' ' | b'\t' | b'\r' | b'\n' => {}
b'#' => {
if self.options.comments {
loop {
match self.cmd_line.get(self.index).copied().unwrap_or(0) {
b'\n' => break,
0 => return false,
_ => self.index += 1,
}
}
// The loop `continue`s, which advances past the newline.
} else {
break;
}
}
_ => break,
}
self.index += 1;
}
true
}
/// Skips the next argument. Returns false at the end.
pub fn skip_arg(&mut self) -> bool {
if !self.skip_whitespace() {
return false;
}
let mut backslash_count: usize = 0;
let mut in_quote = false;
loop {
let c = self.character();
match c {
0 => return true,
b'"' | b'\'' => {
if !self.options.single_quotes && c == b'\'' {
backslash_count = 0;
} else {
let quote_is_real = backslash_count.is_multiple_of(2);
if quote_is_real {
in_quote = !in_quote;
}
}
}
b'\\' => backslash_count += 1,
b' ' | b'\t' | b'\r' | b'\n' => {
if !in_quote {
return true;
}
backslash_count = 0;
}
_ => backslash_count = 0,
}
self.index += 1;
}
}
/// The next argument, or `None` at the end.
pub fn next_arg(&mut self) -> Option<Vec<u8>> {
if !self.skip_whitespace() {
return None;
}
let mut out: Vec<u8> = Vec::new();
let mut backslash_count: usize = 0;
let mut in_quote = false;
let emit_backslashes =
|out: &mut Vec<u8>, n: usize| out.extend(std::iter::repeat_n(b'\\', n));
loop {
let c = self.character();
match c {
0 => {
emit_backslashes(&mut out, backslash_count);
return Some(out);
}
b'"' | b'\'' => {
if !self.options.single_quotes && c == b'\'' {
emit_backslashes(&mut out, backslash_count);
backslash_count = 0;
out.push(c);
} else {
let quote_is_real = backslash_count.is_multiple_of(2);
emit_backslashes(&mut out, backslash_count / 2);
backslash_count = 0;
if quote_is_real {
in_quote = !in_quote;
} else {
out.push(b'"');
}
}
}
b'\\' => backslash_count += 1,
b' ' | b'\t' | b'\r' | b'\n' => {
emit_backslashes(&mut out, backslash_count);
backslash_count = 0;
if in_quote {
out.push(c);
} else {
return Some(out);
}
}
_ => {
emit_backslashes(&mut out, backslash_count);
backslash_count = 0;
out.push(c);
}
}
self.index += 1;
}
}
}
impl Iterator for ArgIteratorGeneral<'_> {
type Item = Vec<u8>;
fn next(&mut self) -> Option<Vec<u8>> {
self.next_arg()
}
}
#[cfg(test)]
#[allow(clippy::byte_char_slices)]
mod tests {
use super::*;
fn response_file(input: &str, expected: &[&str]) {
let it = ArgIteratorGeneral::new(
Options {
comments: true,
single_quotes: true,
},
input.as_bytes(),
);
let got: Vec<Vec<u8>> = it.collect();
let want: Vec<Vec<u8>> = expected.iter().map(|s| s.as_bytes().to_vec()).collect();
assert_eq!(got, want, "{input:?}");
}
#[test]
fn response_file_arg_parsing() {
response_file("a b\nc d\\", &["a", "b", "c", "d\\"]);
response_file("a b c d\\", &["a", "b", "c", "d\\"]);
response_file(
"j\n k l # this is a comment \\\\ \\\\\\ \\\\\\\\ \"none\" \"\\\\\" \"\\\\\\\"\n \"m\" #another comment\n",
&["j", "k", "l", "m"],
);
response_file(
" \"\" q \"\"\n \"r s # t\" \"u\\\" v\" #another comment\n",
&["", "q", "", "r s # t", "u\" v"],
);
response_file(
" -l\"advapi32\" a# b#c d#\ne\\\\\\",
&["-ladvapi32", "a#", "b#c", "d#", "e\\\\\\"],
);
response_file(" 'foo' \"bar\"", &["foo", "bar"]);
}
fn general(input: &[u8]) -> Vec<Vec<u8>> {
ArgIteratorGeneral::new(Options::default(), input).collect()
}
#[test]
fn all_zig_separators_split() {
for sep in [b' ', b'\t', b'\r', b'\n'] {
assert_eq!(
general(&[b'a', sep, b'b']),
vec![b"a".to_vec(), b"b".to_vec()]
);
assert_eq!(general(&[sep, sep, b'a', sep, sep]), vec![b"a".to_vec()]);
}
// VT and FF are not separators.
assert_eq!(general(b"a\x0bb\x0cc"), vec![b"a\x0bb\x0cc".to_vec()]);
// Quoted separators are kept.
assert_eq!(
general(b"\"a\nb\r\tc\" d"),
vec![b"a\nb\r\tc".to_vec(), b"d".to_vec()]
);
}
#[test]
fn quoting_and_backslash_rules() {
assert_eq!(general(b"a\\b"), vec![b"a\\b".to_vec()]);
assert_eq!(general(b"\\\\\\\"x"), vec![b"\\\"x".to_vec()]);
assert_eq!(general(b"\\\\\"x y\""), vec![b"\\x y".to_vec()]);
// Single quotes are ordinary characters by default.
assert_eq!(general(b"'a b'"), vec![b"'a".to_vec(), b"b'".to_vec()]);
// Unterminated quote runs to the end.
assert_eq!(general(b"\"a b"), vec![b"a b".to_vec()]);
// Trailing backslashes are emitted verbatim.
assert_eq!(general(b"a\\\\"), vec![b"a\\\\".to_vec()]);
// Empty quoted argument.
assert_eq!(general(b"\"\" x"), vec![b"".to_vec(), b"x".to_vec()]);
}
#[test]
fn nul_terminates_the_command_line() {
assert_eq!(general(b"a b\0c d"), vec![b"a".to_vec(), b"b".to_vec()]);
assert_eq!(general(b"\0a"), Vec::<Vec<u8>>::new());
assert_eq!(general(b"\"a\0b\" c"), vec![b"a".to_vec()]);
}
#[test]
fn skip_matches_next() {
let mut it = ArgIteratorGeneral::new(Options::default(), b"a \"b c\"\nd");
assert!(it.skip_arg());
assert_eq!(it.next_arg(), Some(b"b c".to_vec()));
assert!(it.skip_arg());
assert!(!it.skip_arg());
}
}
+3 -2
View File
@@ -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::<u32>() else {
// (`parseInt(c_int, ..., 10)`.)
let Ok(gio_pid) = crate::zig_fmt::parse_int::<i32>(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
+2
View File
@@ -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.
+27
View File
@@ -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<std::path::PathBuf> {
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<std::path::PathBuf> {
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();
+872
View File
@@ -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<u8, ParseIntError> {
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<T: ZigInt>(buf: &[u8], base: u8) -> Result<T, ParseIntError> {
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<T: ZigInt>(buf: &[u8], base: u8) -> Result<T, ParseIntError> {
parse_int_with_sign(buf, base, false)
}
fn parse_int_with_sign<T: ZigInt>(
buf: &[u8],
base: u8,
negative: bool,
) -> Result<T, ParseIntError> {
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<u8> {
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<u8> {
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<i64> {
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<Number> {
// 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<Parsed> {
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<T: ZigFloat>(s: &[u8], negative: bool) -> Option<T> {
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<T: ZigFloat>(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<T: ZigFloat>(v: T) -> String {
if v.is_nan_value() {
"nan".to_string()
} else {
v.to_string()
}
}
/// `std.fmt.parseFloat(T, s)`.
pub fn parse_float<T: ZigFloat>(s: &[u8]) -> Result<T, ParseFloatError> {
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::<T>(s, negative).ok_or(ParseFloatError),
Some(Parsed::Hex(n)) => {
debug_assert!(n.hex);
Ok(convert_hex::<T>(&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<T: ZigInt>(s: &str, base: u8) -> Result<T, ParseIntError> {
parse_int::<T>(s.as_bytes(), base)
}
#[test]
fn parse_int_zig_std_test() {
assert_eq!(pi::<i32>("-10", 10), Ok(-10));
assert_eq!(pi::<i32>("+10", 10), Ok(10));
assert_eq!(pi::<u32>("+10", 10), Ok(10));
assert_eq!(pi::<u32>("-10", 10), Err(ParseIntError::Overflow));
for bad in [" 10", "10 ", "_10_", "0x_10_", "0x10_", "0x_10"] {
assert_eq!(pi::<u32>(bad, 10), Err(ParseIntError::InvalidCharacter));
}
assert_eq!(pi::<u8>("255", 10), Ok(255));
assert_eq!(pi::<u8>("256", 10), Err(ParseIntError::Overflow));
// +0 and -0 should work for unsigned
assert_eq!(pi::<u8>("-0", 10), Ok(0));
assert_eq!(pi::<u8>("+0", 10), Ok(0));
assert_eq!(pi::<i8>("-128", 10), Ok(-128));
assert_eq!(pi::<i64>("-4398046511104", 10), Ok(-4_398_046_511_104));
// empty string or bare +- is invalid
for bad in ["", "+", "-"] {
assert_eq!(pi::<u32>(bad, 10), Err(ParseIntError::InvalidCharacter));
assert_eq!(pi::<i32>(bad, 10), Err(ParseIntError::InvalidCharacter));
}
// autodectect the base
assert_eq!(pi::<i32>("111", 0), Ok(111));
assert_eq!(pi::<i32>("1_1_1", 0), Ok(111));
assert_eq!(pi::<i32>("+0b111", 0), Ok(7));
assert_eq!(pi::<i32>("+0B111", 0), Ok(7));
assert_eq!(pi::<i32>("+0b1_11", 0), Ok(7));
assert_eq!(pi::<i32>("+0o111", 0), Ok(73));
assert_eq!(pi::<i32>("+0O111", 0), Ok(73));
assert_eq!(pi::<i32>("+0o11_1", 0), Ok(73));
assert_eq!(pi::<i32>("+0x111", 0), Ok(273));
assert_eq!(pi::<i32>("-0b111", 0), Ok(-7));
assert_eq!(pi::<i32>("-0b11_1", 0), Ok(-7));
assert_eq!(pi::<i32>("-0o111", 0), Ok(-73));
assert_eq!(pi::<i32>("-0x111", 0), Ok(-273));
assert_eq!(pi::<i32>("-0X111", 0), Ok(-273));
assert_eq!(pi::<i32>("-0x1_11", 0), Ok(-273));
// bare binary/octal/decimal prefix is invalid
for bad in ["0b", "0o", "0x"] {
assert_eq!(pi::<u32>(bad, 0), Err(ParseIntError::InvalidCharacter));
}
assert_eq!(pi::<i8>("-10", 2), Ok(-2));
assert_eq!(pi::<i8>("-10", 8), Ok(-8));
assert_eq!(pi::<i8>("-10", 16), Ok(-16));
}
#[test]
fn parse_unsigned_zig_std_test() {
let pu = |s: &str, b| parse_unsigned::<u64>(s.as_bytes(), b);
assert_eq!(parse_unsigned::<u16>(b"050124", 10), Ok(50124));
assert_eq!(parse_unsigned::<u16>(b"65535", 10), Ok(65535));
assert_eq!(parse_unsigned::<u16>(b"65_535", 10), Ok(65535));
assert_eq!(
parse_unsigned::<u16>(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::<u32>(b"DeadBeef", 16), Ok(0xDEAD_BEEF));
assert_eq!(
parse_unsigned::<u32>(b"f", 10),
Err(ParseIntError::InvalidCharacter)
);
assert_eq!(
parse_unsigned::<u8>(b"109", 8),
Err(ParseIntError::InvalidCharacter)
);
assert_eq!(parse_unsigned::<u32>(b"NUMBER", 36), Ok(1_442_151_747));
// parseUnsigned does not expect a sign
assert_eq!(
parse_unsigned::<u8>(b"+0", 10),
Err(ParseIntError::InvalidCharacter)
);
assert_eq!(
parse_unsigned::<u8>(b"-0", 10),
Err(ParseIntError::InvalidCharacter)
);
assert_eq!(
parse_unsigned::<u8>(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::<u32>("0x1", 0), Ok(1));
assert_eq!(pi::<u32>("0_1", 0), Ok(1));
assert_eq!(pi::<u32>("1__0", 0), Ok(10));
assert_eq!(pi::<u32>("0X10", 0), Ok(16));
assert_eq!(
parse_int::<u32>("é1".as_bytes(), 0),
Err(ParseIntError::InvalidCharacter)
);
// Prefixes are only honoured with base 0.
assert_eq!(pi::<u32>("0x10", 10), Err(ParseIntError::InvalidCharacter));
assert_eq!(pi::<u32>("0b11", 16), Ok(0xb11));
}
fn pf64(s: &str) -> Result<f64, ParseFloatError> {
parse_float::<f64>(s.as_bytes())
}
fn pf32(s: &str) -> Result<f32, ParseFloatError> {
parse_float::<f32>(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());
}
}
+2 -2
View File
@@ -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<G: GraphicsApi> DrawState<G> {
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))
@@ -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();