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 7480f99d91
commit 79a7fd4f76
26 files changed
+1835 -228

No files matched your search

+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]))?;