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:
1 parent
61e7a680ac
commit
a57ff41d34
26 files changed
+1835
-228
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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]))?;
|
||||
|
||||
Reference in new issue
Block a user