//! Generic CLI / config-line argument parser (port of `cli/args.zig`). //! //! The Zig implementation uses comptime reflection over the destination //! struct. In Rust this is done with two traits: //! //! * [`ConfigField`]: implemented by every type that can be a field value //! (bool, ints, floats, strings, enums, repeatable lists, ...). It knows //! how to parse itself from a `--key=value` value and how to format itself //! in the `+show-config` format. //! * [`CliTarget`]: implemented by the destination struct (usually generated //! with the [`config_struct!`](crate::config_struct) macro). It maps a key //! to a field and optionally provides diagnostics, compatibility handlers, //! a manual-parse hook and a `--help` hook. use std::{fmt, io::BufRead}; use crate::{ c_value::CValue, comma_splitter::CommaSplitter, diagnostics::{Diagnostic, DiagnosticList, Location}, formatter::EntryFormatter, }; /// For trimming pub const WHITESPACE: &str = " \t"; /// Trim [`WHITESPACE`] characters from both ends. #[must_use] pub fn trim_ws(s: &str) -> &str { s.trim_matches(|c| c == ' ' || c == '\t') } /// Trim Zig's `std.ascii.whitespace`, including vertical tab. pub fn trim_ascii_whitespace(value: &[u8]) -> &[u8] { let is_whitespace = |b: &u8| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c); let start = value .iter() .position(|b| !is_whitespace(b)) .unwrap_or(value.len()); let end = value .iter() .rposition(|b| !is_whitespace(b)) .map_or(start, |i| i + 1); &value[start..end] } /// Validate at a text field's parsing boundary, never in the line/argument reader. pub fn text_value(value: Option<&[u8]>) -> Result, ArgError> { value .map(|v| std::str::from_utf8(v).map_err(|_| ArgError::InvalidValue)) .transpose() } pub fn trim_bytes(value: &[u8]) -> &[u8] { let start = value .iter() .position(|b| !matches!(b, b' ' | b'\t')) .unwrap_or(value.len()); let end = value .iter() .rposition(|b| !matches!(b, b' ' | b'\t')) .map_or(start, |i| i + 1); &value[start..end] } /// The base errors for arg parsing. Additional errors can be returned due /// to type-specific parsing but these are always possible. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ArgError { ValueRequired, InvalidField, InvalidValue, /// A type-specific error. The string is the (Zig) error name and is /// shown to the user as `unknown error `. Other(String), } impl ArgError { #[must_use] pub fn other(name: &str) -> Self { ArgError::Other(name.to_string()) } } impl fmt::Display for ArgError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ArgError::ValueRequired => f.write_str("ValueRequired"), ArgError::InvalidField => f.write_str("InvalidField"), ArgError::InvalidValue => f.write_str("InvalidValue"), ArgError::Other(s) => f.write_str(s), } } } impl std::error::Error for ArgError {} impl From for ArgError { fn from(e: crate::comma_splitter::Error) -> Self { ArgError::Other(e.to_string()) } } /// Coarse classification of a field's Zig type, used by the build-data /// generator (shell completions) which switches on `@typeInfo(field.type)`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FieldKind { /// `bool` Bool, /// `?bool` OptionalBool, /// An enum with the given variant names. Enum(&'static [&'static str]), /// `?Enum` OptionalEnum(&'static [&'static str]), /// An optional of any other type (`?T`). OptionalOther, /// A packed struct of bools without `parseCLI`, with its field names. Packed(&'static [&'static str]), /// `RepeatablePath` RepeatablePath, /// Anything else. Other, } /// A type that can be the value of a configuration / CLI field. pub trait ConfigField: Clone + PartialEq + Default + fmt::Debug { /// For enum types, the list of valid values (used in error messages). const VALID_VALUES: Option<&'static [&'static str]> = None; /// Classification of this type for completion generation. const KIND: FieldKind = match Self::VALID_VALUES { Some(v) => FieldKind::Enum(v), None => FieldKind::Other, }; /// Parse `value` (the text after `=`, `None` if there was no `=`) into /// `self`. Repeatable types accumulate; scalar types overwrite. fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError>; /// Convenience for plain UTF-8 input. Never accepts the OS escape encoding. fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { self.parse_bytes(value.map(str::as_bytes)) } /// Called when the value was set to the empty string: reset to the /// field's default value (Zig: the `init` function or the declared /// default). fn reset(&mut self, default: Self) { *self = default; } /// Write this value in the `+show-config` format. fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result; /// The value in the form the C API exposes (Zig: `c_get.getValue`). /// Types the C API doesn't support yet return [`CValue::Unsupported`]. fn c_value(&self) -> CValue { CValue::Unsupported } /// [`ConfigField::c_value`] when the value is wrapped in an `Option` /// (only `String` differs: `?[:0]const u8` is supported, `[]const u8` /// is not). fn c_value_optional(&self) -> CValue { self.c_value() } /// The C value of an `Option` that is `None`. fn c_value_none() -> CValue { if matches!(Self::default().c_value(), CValue::Unsupported) { CValue::Unsupported } else { CValue::Null } } } /// The destination of CLI parsing. pub trait CliTarget { /// Parse a single key/value pair into the destination /// (Zig: `parseIntoField`). Returns `ArgError::InvalidField` if the key /// is unknown. fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError>; /// For enum-typed fields, the valid values (used for diagnostics). fn valid_values(&self, _key: &str) -> Option<&'static [&'static str]> { None } /// The diagnostic list, if supported (Zig: `_diagnostics` field). fn diagnostics_mut(&mut self) -> Option<&mut DiagnosticList> { None } /// Manual parse hook (Zig: `parseManuallyHook`). Return `Ok(false)` to /// stop parsing. fn parse_manually_hook( &mut self, _arg: &[u8], _iter: &mut dyn ArgIter, ) -> Result { Ok(true) } /// Whether `--help`/`-h` is supported (Zig: `help` decl). If so, /// [`CliTarget::help`] is called when seen. fn supports_help(&self) -> bool { false } fn help(&mut self) -> Result<(), ArgError> { Ok(()) } /// Compatibility handler (Zig: `compatibility` decl). Called when /// parsing a field fails. Return true if handled. fn compatibility(&mut self, _key: &str, _value: Option<&[u8]>) -> bool { false } } /// An iterator over arguments (Zig: any iterator with `next`). pub trait ArgIter { fn next_arg(&mut self) -> Option>; /// Location for diagnostics. fn location(&self) -> Option { None } } /// Parse the command line arguments from iter into dst. /// /// If the destination reports diagnostics (see /// [`CliTarget::diagnostics_mut`]) then errors for individual fields are /// added to the list rather than returned. pub fn parse(dst: &mut T, iter: &mut dyn ArgIter) -> Result<(), ArgError> { while let Some(arg) = iter.next_arg() { // Do manual parsing if we have a hook for it. if !dst.parse_manually_hook(&arg, iter)? { return Ok(()); } // If the destination supports help then we check for it, call // the help function and return. if dst.supports_help() && (arg == b"--help" || arg == b"-h") { dst.help()?; return Ok(()); } // If this doesn't start with "--" then it isn't a config // flag. We don't support positional arguments or configuration // values set with spaces so this is an error. let Some(rest) = arg.strip_prefix(b"--") else { let loc = iter.location().unwrap_or_default(); let Some(diags) = dst.diagnostics_mut() else { return Err(ArgError::InvalidField); }; diags.append(Diagnostic { key: ghostty_os::osbytes::bytes_to_config_string(&arg), message: "invalid field".to_string(), location: loc, }); continue; }; let (key, value) = match rest.iter().position(|b| *b == b'=') { Some(idx) => (&rest[..idx], Some(&rest[idx + 1..])), None => (rest, None), }; // Keys are ASCII names. Keep arbitrary unknown keys byte-faithful in diagnostics. let key = ghostty_os::osbytes::bytes_to_config_string(key); let key = key.as_str(); if let Err(err) = dst.parse_into_field(key, value) { // Fall back to compatibility handlers if able. if dst.compatibility(key, value) { log::info!( "compatibility handler for {key} handled error, you may be using a deprecated field: {err}" ); continue; } let loc = iter.location().unwrap_or_default(); let message = match &err { ArgError::InvalidField => "unknown field".to_string(), ArgError::ValueRequired => { let mut m = String::from("value required"); format_values(dst.valid_values(key), &mut m); m } ArgError::InvalidValue => { let mut m = match value { Some(v) => format!( "invalid value \"{}\"", ghostty_os::osbytes::bytes_to_config_string(v) ), None => "invalid value \"null\"".to_string(), }; format_values(dst.valid_values(key), &mut m); m } ArgError::Other(name) => format!("unknown error error.{name}"), }; let Some(diags) = dst.diagnostics_mut() else { return Err(err); }; diags.append(Diagnostic { key: key.to_string(), message, location: loc, }); } } Ok(()) } fn format_values(values: Option<&[&str]>, out: &mut String) { if let Some(values) = values { out.push_str(", valid values are: "); out.push_str(&values.join(", ")); } } /// Convenience for a compatibility handler that renames a field: parses /// `value` into the field `to` of `dst`. Returns true if it succeeded. pub fn compatibility_renamed( dst: &mut T, to: &str, value: Option<&[u8]>, ) -> bool { match dst.parse_into_field(to, value) { Ok(()) => true, Err(err) => { log::warn!("error parsing renamed field {to}: {err}"); false } } } /// Split a tagged-union value `tag:value` (Zig: `parseTaggedUnion`). The tag /// is trimmed of whitespace. If there is no colon the value is empty. #[must_use] pub fn split_tagged_union(v: &str) -> (&str, &str) { match v.find(':') { Some(idx) => (trim_ws(&v[..idx]), &v[idx + 1..]), None => (trim_ws(v), ""), } } /// Split an ASCII union tag while preserving arbitrary payload bytes. pub fn split_tagged_union_bytes(v: &[u8]) -> (&[u8], &[u8]) { match v.iter().position(|b| *b == b':') { Some(i) => (trim_bytes(&v[..i]), &v[i + 1..]), None => (trim_bytes(v), b""), } } /// Parse an "auto struct" value like `a:hello,b:42` (Zig: `parseAutoStruct`). /// /// Entries are split by commas (with quoting / escaping, see /// [`CommaSplitter`]). Each entry must be `key:value`; the value is trimmed /// and, if double-quoted, decoded as a Zig string literal. For every entry /// `set(key, value)` is called; it must return `Ok(true)` if the key matched /// a field, `Ok(false)` if the key is unknown (which yields `InvalidValue`). /// The caller is responsible for tracking which fields were set and for /// applying defaults / required-field checks afterwards. pub fn parse_auto_struct_with( v: &str, mut set: impl FnMut(&str, &str) -> Result, ) -> Result<(), ArgError> { parse_auto_struct_bytes_with(v.as_bytes(), |k, v| { set( k, std::str::from_utf8(v).map_err(|_| ArgError::InvalidValue)?, ) }) } pub fn parse_auto_struct_bytes_with( v: &[u8], mut set: impl FnMut(&str, &[u8]) -> Result, ) -> Result<(), ArgError> { let mut iter = CommaSplitter::from_bytes(v); while let Some(entry) = iter.next_bytes()? { let idx = entry .iter() .position(|b| *b == b':') .ok_or(ArgError::InvalidValue)?; let key = trim_bytes(&entry[..idx]); let raw = trim_bytes(&entry[idx + 1..]); let decoded; let value = if raw.len() >= 2 && raw.starts_with(b"\"") && raw.ends_with(b"\"") { decoded = parse_zig_string_literal_bytes(raw).ok_or(ArgError::InvalidValue)?; decoded.as_slice() } else { raw }; if !set( std::str::from_utf8(key).map_err(|_| ArgError::InvalidValue)?, value, )? { return Err(ArgError::InvalidValue); } } Ok(()) } /// Decode a double-quoted Zig string literal (including the quotes). /// Returns `None` on failure or invalid UTF-8. Byte fields use /// [`parse_zig_string_literal_bytes`] to retain arbitrary literal and escaped bytes. #[must_use] pub fn parse_zig_string_literal(s: &str) -> Option { String::from_utf8(parse_zig_string_literal_bytes(s)?).ok() } /// Byte-exact [`parse_zig_string_literal`] (Zig `std.zig.string_literal` /// decoding yields a `[]u8`). #[must_use] pub fn parse_zig_string_literal_bytes(s: impl AsRef<[u8]>) -> Option> { let b = s.as_ref(); if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' { return None; } let inner = &b[1..b.len() - 1]; let mut out: Vec = Vec::with_capacity(inner.len()); let mut i = 0; while i < inner.len() { let c = inner[i]; match c { b'\\' => { i += 1; let e = *inner.get(i)?; match e { b'n' => out.push(b'\n'), b'r' => out.push(b'\r'), b't' => out.push(b'\t'), b'\\' => out.push(b'\\'), b'\'' => out.push(b'\''), b'"' => out.push(b'"'), b'x' => { let h = inner.get(i + 1..i + 3)?; let h = std::str::from_utf8(h).ok()?; out.push(u8::from_str_radix(h, 16).ok()?); i += 2; } b'u' => { if *inner.get(i + 1)? != b'{' { return None; } let close = inner[i + 2..].iter().position(|&x| x == b'}')? + i + 2; let h = std::str::from_utf8(&inner[i + 2..close]).ok()?; if h.is_empty() { return None; } let cp = u32::from_str_radix(h, 16).ok()?; let ch = char::from_u32(cp)?; let mut buf = [0u8; 4]; out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); i = close; } _ => return None, } } b'"' | b'\n' => return None, _ => out.push(c), } i += 1; } Some(out) } /// Parse a "packed struct" value (comma separated names, optionally /// prefixed with `no-`, or a standalone boolean). `names` are the field /// names; `set(index, value)` is called for each setting. pub fn parse_packed_struct_with( v: &str, names: &[&str], mut set: impl FnMut(usize, bool), ) -> Result<(), ArgError> { // Allow standalone boolean values like "true" and "false" to // turn on or off all of the struct's fields. if let Ok(b) = parse_bool(v) { for i in 0..names.len() { set(i, b); } return Ok(()); } for part_raw in v.split(',') { let trimmed = trim_ws(part_raw); let (part, value) = match trimmed.strip_prefix("no-") { Some(rest) => (rest, false), None => (trimmed, true), }; match names.iter().position(|n| *n == part) { Some(i) => set(i, value), None => return Err(ArgError::InvalidValue), } } Ok(()) } /// Zig's `@max` on floats: a NaN operand yields the other operand and /// `max(+0, -0)` is `+0`. #[must_use] pub fn zig_max(a: f64, b: f64) -> f64 { if a.is_nan() { b } else if b.is_nan() { a } else if a == b { if a.is_sign_negative() { b } else { a } } else { a.max(b) } } /// Zig's `@min` on floats: a NaN operand yields the other operand and /// `min(+0, -0)` is `-0`. #[must_use] pub fn zig_min(a: f64, b: f64) -> f64 { if a.is_nan() { b } else if b.is_nan() { a } else if a == b { if a.is_sign_negative() { a } else { b } } else { a.min(b) } } /// `std.math.clamp` on floats (`@max(lower, @min(val, upper))`). #[must_use] pub fn zig_clamp(val: f64, lower: f64, upper: f64) -> f64 { zig_max(lower, zig_min(val, upper)) } pub fn parse_bool(v: &str) -> Result { match v { "1" | "t" | "T" | "true" => Ok(true), "0" | "f" | "F" | "false" => Ok(false), _ => Err(ArgError::InvalidValue), } } // --------------------------------------------------------------------- // ConfigField impls for primitives impl ConfigField for bool { const KIND: FieldKind = FieldKind::Bool; fn c_value(&self) -> CValue { CValue::Bool(*self) } fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { let value = crate::cli_args::text_value(value)?; *self = parse_bool(value.unwrap_or("t"))?; Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { writeln!(f.writer, "{} = {}", f.name, self) } } macro_rules! impl_int_field { ($($t:ty => $cv:expr),*) => {$( impl ConfigField for $t { fn c_value(&self) -> CValue { let f: fn($t) -> CValue = $cv; f(*self) } fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { let v = value.ok_or(ArgError::ValueRequired)?; *self = ghostty_os::zig_fmt::parse_int::<$t>(v, 0) .map_err(|_| ArgError::InvalidValue)?; Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { writeln!(f.writer, "{} = {}", f.name, self) } } )*}; } impl_int_field!( u8 => |v| CValue::Uint(u32::from(v)), u16 => |_| CValue::Unsupported, u32 => CValue::Uint, u64 => |_| CValue::Unsupported, usize => |_| CValue::Unsupported, i8 => |_| CValue::Unsupported, i16 => CValue::Short, i32 => |_| CValue::Unsupported, i64 => |_| CValue::Unsupported, isize => |_| CValue::Unsupported ); /// Zig `u21` (a Unicode scalar range integer, `0..=0x1F_FFFF`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct U21(pub u32); impl U21 { pub const MAX: u32 = 0x1f_ffff; } impl ConfigField for U21 { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { let v = value.ok_or(ArgError::ValueRequired)?; let n = ghostty_os::zig_fmt::parse_int::(v, 0).map_err(|_| ArgError::InvalidValue)?; if n > Self::MAX { return Err(ArgError::InvalidValue); } *self = U21(n); Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { writeln!(f.writer, "{} = {}", f.name, self.0) } } macro_rules! impl_float_field { ($($t:ty => $cv:expr),*) => {$( impl ConfigField for $t { fn c_value(&self) -> CValue { let f: fn($t) -> CValue = $cv; f(*self) } fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { let v = value.ok_or(ArgError::ValueRequired)?; *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, ghostty_os::zig_fmt::format_float_d(*self)) } } )*}; } impl_float_field!(f32 => CValue::F32, f64 => CValue::F64); impl ConfigField for String { fn c_value_optional(&self) -> CValue { CValue::String(ghostty_os::osbytes::valid_str_to_config_string(self)) } fn c_value_none() -> CValue { CValue::NullString } fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { let value = crate::cli_args::text_value(value)?; *self = value.ok_or(ArgError::ValueRequired)?.to_string(); Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { writeln!( f.writer, "{} = {}", f.name, ghostty_os::osbytes::valid_str_to_config_string(self) ) } } impl ConfigField for ghostty_os::osbytes::EncodedOs { fn c_value_optional(&self) -> CValue { CValue::String(self.as_encoded().to_owned()) } fn c_value_none() -> CValue { CValue::NullString } fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { *self = Self::from_bytes(value.ok_or(ArgError::ValueRequired)?); Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { writeln!(f.writer, "{} = {}", f.name, self.as_encoded()) } } impl ConfigField for Option { const KIND: FieldKind = match T::KIND { FieldKind::Bool => FieldKind::OptionalBool, FieldKind::Enum(v) => FieldKind::OptionalEnum(v), _ => FieldKind::OptionalOther, }; fn c_value(&self) -> CValue { match self { Some(v) => v.c_value_optional(), None => T::c_value_none(), } } fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> { // For optional fields, we just treat it as the child type. if let Some(v) = self { v.parse_bytes(value) } else { let mut tmp = T::default(); tmp.parse_bytes(value)?; *self = Some(tmp); Ok(()) } } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { match self { Some(v) => v.format_entry(f), None => writeln!(f.writer, "{} = ", f.name), } } } // --------------------------------------------------------------------- // Iterators /// An iterator that considers its location to be CLI args. It /// iterates through an underlying iterator and increments a counter /// to track the current CLI arg index. /// /// This also ignores any argument that starts with `+`. It assumes that /// actions were parsed out before this iterator was created. pub struct ArgsIterator { /// The underlying args iterator. pub iterator: I, /// Our current index into the iterator. This is 1-indexed because /// the 0 value is used to indicate that we haven't read any values yet. pub index: usize, } impl ArgsIterator where I::Item: AsRef<[u8]>, { pub fn new(iterator: I) -> Self { Self { iterator, index: 0 } } pub fn next(&mut self) -> Option> { loop { let value = self.iterator.next()?; self.index += 1; // We ignore any argument that starts with "+". This is used // to indicate actions and are expected to be parsed out before // this iterator is created. if value.as_ref().starts_with(b"+") { continue; } return Some(value.as_ref().to_vec()); } } } impl ArgIter for ArgsIterator where I::Item: AsRef<[u8]>, { fn next_arg(&mut self) -> Option> { self.next() } fn location(&self) -> Option { Some(Location::Cli(self.index)) } } /// Create an args iterator for the process args. This will skip argv0. #[must_use] pub fn args_iterator() -> ArgsIterator>>> { // Keep original bytes until each field chooses whether to validate UTF-8. ArgsIterator::new(ghostty_os::args::collect_bytes().into_iter().skip(1)) } /// An iterator valid for arg parsing from a slice. pub struct SliceIterator<'a, T> { pub slice: &'a [T], pub idx: usize, } impl> ArgIter for SliceIterator<'_, T> { fn next_arg(&mut self) -> Option> { let v = self.slice.get(self.idx)?; self.idx += 1; Some(v.as_ref().to_vec()) } } /// Construct a [`SliceIterator`] from a slice. #[must_use] pub fn slice_iterator>(slice: &[T]) -> SliceIterator<'_, T> { SliceIterator { slice, idx: 0 } } /// A simple whitespace-splitting iterator used in tests and for /// string-based argument parsing. pub struct VecIterator { items: std::vec::IntoIter>, } impl VecIterator { #[must_use] pub fn new>(items: Vec) -> Self { Self { items: items .into_iter() .map(|v| v.as_ref().to_vec()) .collect::>() .into_iter(), } } /// Split on spaces (no quoting except double quotes around a value). #[must_use] pub fn from_str_args(s: &str) -> Self { Self::new(split_args(s)) } } impl ArgIter for VecIterator { fn next_arg(&mut self) -> Option> { self.items.next() } } /// Split a string into args like `std.process.ArgIteratorGeneral`: whitespace /// separated, double quotes group (and are removed). #[must_use] pub fn split_args(s: &str) -> Vec { let mut out = vec![]; let mut cur = String::new(); let mut in_arg = false; let mut quoted = false; for c in s.chars() { match c { '"' => { quoted = !quoted; in_arg = true; } c if c.is_whitespace() && !quoted => { if in_arg { out.push(std::mem::take(&mut cur)); in_arg = false; } } c => { cur.push(c); in_arg = true; } } } if in_arg { out.push(cur); } out } /// Reads CLI args by line. Each CLI arg is expected to be a single line. /// This is used to implement configuration files. pub struct LineIterator { /// Our stateful reader. pub r: R, /// Filepath that is used for diagnostics. It is only used for /// diagnostic messages so it can be formatted however you want. pub filepath: String, /// The current line that we're on. 1-indexed; zero if we haven't read /// any lines yet. pub line: usize, } impl LineIterator { /// The maximum size a single line can be. We don't expect any /// CLI arg to exceed this size. pub const MAX_LINE_SIZE: usize = 4096; pub fn new(r: R) -> Self { Self { r, filepath: String::new(), line: 0, } } pub fn with_path(r: R, filepath: impl Into) -> Self { Self { r, filepath: filepath.into(), line: 0, } } pub fn next(&mut self) -> Option> { let mut buf = Vec::new(); let entry: Vec = loop { buf.clear(); match self.r.read_until(b'\n', &mut buf) { Ok(0) => return None, Ok(_) => {} Err(e) => { log::warn!("cannot read from \"{}\": {e}", self.filepath); return None; } } if buf.last() == Some(&b'\n') { buf.pop(); } if buf.len() > Self::MAX_LINE_SIZE - 2 { log::warn!("cannot read from \"{}\": StreamTooLong", self.filepath); return None; } self.line += 1; // Zig trims spaces, tabs and carriage returns, without decoding text. let start = buf .iter() .position(|b| !matches!(b, b' ' | b'\t' | b'\r')) .unwrap_or(buf.len()); let end = buf .iter() .rposition(|b| !matches!(b, b' ' | b'\t' | b'\r')) .map_or(start, |i| i + 1); let trim = &buf[start..end]; // Ignore blank lines and comments if trim.is_empty() || trim.starts_with(b"#") { continue; } break trim.to_vec(); }; let mut result = b"--".to_vec(); if let Some(idx) = entry.iter().position(|b| *b == b'=') { let key = trim_bytes(&entry[..idx]); let mut value = trim_bytes(&entry[idx + 1..]); // Detect a quoted string. if value.len() >= 2 && value.starts_with(b"\"") && value.ends_with(b"\"") { // Trim quotes since our CLI args processor expects // quotes to already be gone. value = &value[1..value.len() - 1]; } result.extend_from_slice(key); result.push(b'='); result.extend_from_slice(value); } else { result.extend_from_slice(&entry); } Some(result) } } impl ArgIter for LineIterator { fn next_arg(&mut self) -> Option> { self.next() } fn location(&self) -> Option { // If we have no filepath then we have no location. if self.filepath.is_empty() { return None; } Some(Location::File { path: self.filepath.clone(), line: self.line, }) } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use std::io::Cursor; use super::*; use crate::{config_enum, config_packed, config_struct}; fn parse_str(dst: &mut T, s: &str) -> Result<(), ArgError> { let mut it = VecIterator::from_str_args(s); parse(dst, &mut it) } // ---- test structs ------------------------------------------------- config_struct! { pub struct Simple { "a" => a: String = String::new(), "b" => b: bool = false, "b-f" => b_f: bool = true, } extra { diags: DiagnosticList = DiagnosticList::default(), } } impl Default for Simple { fn default() -> Self { Self::raw_default() } } impl CliTarget for Simple { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> { self.parse_field_bytes(key, value) } } config_struct! { pub struct Quoted { "a" => a: u8 = 0, "b" => b: String = String::new(), } } impl CliTarget for Quoted { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> { self.parse_field_bytes(key, value) } } config_struct! { pub struct Reset { "a" => a: u8 = 42, "b" => b: bool = false, } } impl CliTarget for Reset { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> { self.parse_field_bytes(key, value) } } config_struct! { pub struct Cp { "cp" => cp: Option = None, } } #[test] fn u21_field_matches_zig_range() { let mut c = Cp::raw_default(); c.parse_field("cp", Some("0x1FFFFF")).unwrap(); assert_eq!(c.cp, Some(U21(0x1f_ffff))); c.parse_field("cp", Some("0b1000001")).unwrap(); assert_eq!(c.cp, Some(U21(65))); for bad in ["0x200000", "4294967295", "99999999999999999999", "-1", "x"] { assert_eq!( c.parse_field("cp", Some(bad)), Err(ArgError::InvalidValue), "{bad}" ); } assert_eq!(c.parse_field("cp", None), Err(ArgError::ValueRequired)); } config_enum! { pub enum OneTwo { One => "one", Two => "two" } } config_struct! { pub struct Diag { "a" => a: String = String::new(), "b" => b: OneTwo = OneTwo::One, } extra { diags: DiagnosticList = DiagnosticList::default(), } } impl CliTarget for Diag { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> { self.parse_field_bytes(key, value) } fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> { Self::valid_values_for(key) } fn diagnostics_mut(&mut self) -> Option<&mut DiagnosticList> { Some(&mut self.diags) } } #[test] fn parse_simple() { let mut data = Simple::raw_default(); parse_str(&mut data, "--a=42 --b --b-f=false").unwrap(); assert_eq!(data.a, "42"); assert!(data.b); assert!(!data.b_f); // Reparsing works parse_str(&mut data, "--a=84").unwrap(); assert_eq!(data.a, "84"); assert!(data.b); assert!(!data.b_f); } #[test] fn parse_quoted_value() { let mut data = Quoted::raw_default(); parse_str(&mut data, "--a=\"42\" --b=\"hello!\"").unwrap(); assert_eq!(data.a, 42); assert_eq!(data.b, "hello!"); } #[test] fn parse_empty_value_resets_to_default() { let mut data = Reset::raw_default(); parse_str(&mut data, "--a= --b=").unwrap(); assert_eq!(data.a, 42); assert!(!data.b); } #[test] fn parse_positional_arguments_are_invalid() { let mut data = Reset::raw_default(); data.a = 0; let r = parse_str(&mut data, "--a=84 what"); assert_eq!(r, Err(ArgError::InvalidField)); assert_eq!(data.a, 84); } #[test] fn parse_diagnostic_tracking() { let mut data = Diag::raw_default(); parse_str(&mut data, "--what --a=42").unwrap(); assert_eq!(data.a, "42"); assert_eq!(data.diags.items().len(), 1); let diag = &data.diags.items()[0]; assert_eq!(diag.location, Location::None); assert_eq!(diag.key, "what"); assert_eq!(diag.message, "unknown field"); } #[test] fn parse_diagnostic_location() { let mut data = Diag::raw_default(); let mut iter = LineIterator::with_path(Cursor::new("a=42\nwhat\nb=two"), "test"); parse(&mut data, &mut iter).unwrap(); assert_eq!(data.a, "42"); assert_eq!(data.b, OneTwo::Two); assert_eq!(data.diags.items().len(), 1); let diag = &data.diags.items()[0]; assert_eq!(diag.key, "what"); assert_eq!(diag.message, "unknown field"); assert_eq!( diag.location, Location::File { path: "test".into(), line: 2 } ); } #[test] fn parse_diagnostic_invalid_enum_value_lists_valid_values() { let mut data = Diag::raw_default(); parse_str(&mut data, "--b=three").unwrap(); assert_eq!( data.diags.items()[0].message, "invalid value \"three\", valid values are: one, two" ); } pub struct Compat { pub a: bool, } impl CliTarget for Compat { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> { if key == "a" { // Fails for anything but a valid bool. return self.a.parse_bytes(value); } Err(ArgError::InvalidField) } fn compatibility(&mut self, key: &str, value: Option<&[u8]>) -> bool { if key == "a" && value == Some(b"yuh") { self.a = true; return true; } false } } #[test] fn parse_compatibility_handler() { let mut data = Compat { a: false }; parse_str(&mut data, "--a=yuh").unwrap(); assert!(data.a); } pub struct Renamed { pub a: bool, pub b: bool, } impl CliTarget for Renamed { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> { match key { "a" => self.a.parse_bytes(value), "b" => self.b.parse_bytes(value), _ => Err(ArgError::InvalidField), } } fn compatibility(&mut self, key: &str, value: Option<&[u8]>) -> bool { if key == "old" { return compatibility_renamed(self, "a", value); } false } } #[test] fn parse_compatibility_renamed() { let mut data = Renamed { a: false, b: false }; parse_str(&mut data, "--old=true --b=true").unwrap(); assert!(data.a); assert!(data.b); } config_struct! { pub struct Underscore { "a" => a: String = "12".to_string(), } } impl CliTarget for Underscore { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> { // Underscore-prefixed fields are never parseable. if key.starts_with('_') { return Err(ArgError::InvalidField); } self.parse_field_bytes(key, value) } } #[test] fn parse_into_field_ignore_underscore_prefixed_fields() { let mut data = Underscore::raw_default(); assert_eq!( data.parse_into_field("_a", Some(b"42")), Err(ArgError::InvalidField) ); assert_eq!(data.a, "12"); } #[derive(Clone, PartialEq, Debug)] pub struct WithInit { pub v: String, } impl Default for WithInit { fn default() -> Self { Self { v: "default".into(), } } } impl ConfigField for WithInit { fn parse_bytes(&mut self, _value: Option<&[u8]>) -> Result<(), ArgError> { let _value = crate::cli_args::text_value(_value)?; Ok(()) } fn reset(&mut self, _default: Self) { // `init` self.v = "HELLO!".into(); } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { writeln!(f.writer, "{} = {}", f.name, self.v) } } config_struct! { pub struct HasInit { "a" => a: WithInit = WithInit::default(), } } #[test] fn parse_into_field_struct_with_init_func() { let mut data = HasInit::raw_default(); data.parse_field("a", Some("")).unwrap(); assert_eq!(data.a.v, "HELLO!"); } macro_rules! one_field { ($name:ident, $ty:ty, $def:expr) => { config_struct! { pub struct $name { "v" => v: $ty = $def, } } }; } #[test] fn parse_into_field_string() { one_field!(S, String, String::new()); let mut d = S::raw_default(); d.parse_field("v", Some("42")).unwrap(); assert_eq!(d.v, "42"); } #[test] fn parse_into_field_bool() { one_field!(S, bool, false); let mut d = S::raw_default(); for t in ["1", "t", "T", "true"] { d.parse_field("v", Some(t)).unwrap(); assert!(d.v); d.v = false; } for f in ["0", "f", "F", "false"] { d.v = true; d.parse_field("v", Some(f)).unwrap(); assert!(!d.v); } } #[test] fn parse_into_field_unsigned_numbers() { one_field!(S, u8, 0); let mut d = S::raw_default(); d.parse_field("v", Some("1")).unwrap(); assert_eq!(d.v, 1); d.parse_field("v", Some("0x10")).unwrap(); assert_eq!(d.v, 16); assert_eq!(d.parse_field("v", Some("256")), Err(ArgError::InvalidValue)); assert_eq!(d.parse_field("v", Some("-1")), Err(ArgError::InvalidValue)); } #[test] fn parse_into_field_floats() { one_field!(S, f64, 0.0); let mut d = S::raw_default(); d.parse_field("v", Some("1")).unwrap(); assert!((d.v - 1.0).abs() < f64::EPSILON); } config_enum! { pub enum Three { One => "one", Two => "two", Three => "three" } } #[test] fn parse_into_field_enums() { one_field!(S, Three, Three::One); let mut d = S::raw_default(); d.parse_field("v", Some("two")).unwrap(); assert_eq!(d.v, Three::Two); } config_packed! { pub struct Pk { "a" => a: false, "b" => b: true, } } #[test] fn parse_into_field_packed_struct() { one_field!(S, Pk, Pk::default()); let mut d = S::raw_default(); d.parse_field("v", Some("b")).unwrap(); assert!(!d.v.a); assert!(d.v.b); } #[test] fn parse_into_field_packed_struct_negation() { one_field!(S, Pk, Pk::default()); let mut d = S::raw_default(); d.parse_field("v", Some("a,no-b")).unwrap(); assert!(d.v.a); assert!(!d.v.b); } #[test] fn parse_into_field_packed_struct_true_false() { one_field!(S, Pk, Pk::default()); let mut d = S::raw_default(); d.parse_field("v", Some("true")).unwrap(); assert!(d.v.a && d.v.b); d.parse_field("v", Some("false")).unwrap(); assert!(!d.v.a && !d.v.b); assert_eq!( d.parse_field("v", Some("true,a")), Err(ArgError::InvalidValue) ); } #[test] fn parse_into_field_packed_struct_whitespace() { one_field!(S, Pk, Pk::default()); let mut d = S::raw_default(); d.parse_field("v", Some(" a, no-b ")).unwrap(); assert!(d.v.a); assert!(!d.v.b); } #[test] fn parse_into_field_optional_field() { one_field!(S, Option, None); let mut d = S::raw_default(); d.parse_field("v", Some("1")).unwrap(); assert_eq!(d.v, Some(true)); // Unset d.parse_field("v", Some("")).unwrap(); assert_eq!(d.v, None); } #[derive(Clone, PartialEq, Debug, Default)] pub struct WithParse { pub v: String, } impl ConfigField for WithParse { fn parse_bytes(&mut self, _value: Option<&[u8]>) -> Result<(), ArgError> { let _value = crate::cli_args::text_value(_value)?; self.v = "HELLO!".into(); Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { writeln!(f.writer, "{} = {}", f.name, self.v) } } #[test] fn parse_into_field_struct_with_parse_func() { one_field!(S, WithParse, WithParse::default()); let mut d = S::raw_default(); d.parse_field("v", Some("42")).unwrap(); assert_eq!(d.v.v, "HELLO!"); } #[test] fn parse_into_field_optional_struct_with_parse_func() { one_field!(S, Option, None); let mut d = S::raw_default(); d.parse_field("v", Some("42")).unwrap(); assert_eq!(d.v.unwrap().v, "HELLO!"); } #[derive(Clone, PartialEq, Debug)] pub struct Basic { pub a: String, pub b: u32, pub c: u8, } impl Default for Basic { fn default() -> Self { Self { a: String::new(), b: 0, c: 12, } } } impl ConfigField for Basic { 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 mut r = Basic::default(); let mut set = [false; 3]; parse_auto_struct_with(v, |k, val| { match k { "a" => { r.a.parse_cli(Some(val))?; set[0] = true; } "b" => { r.b.parse_cli(Some(val))?; set[1] = true; } "c" => { r.c.parse_cli(Some(val))?; set[2] = true; } _ => return Ok(false), } Ok(true) })?; // a and b are required if !set[0] || !set[1] { return Err(ArgError::InvalidValue); } *self = r; Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { writeln!( f.writer, "{} = a:{},b:{},c:{}", f.name, self.a, self.b, self.c ) } } #[test] fn parse_into_field_struct_with_basic_fields() { one_field!(S, Basic, Basic::default()); let mut d = S::raw_default(); d.parse_field("v", Some("a:hello,b:42")).unwrap(); assert_eq!(d.v.a, "hello"); assert_eq!(d.v.b, 42); assert_eq!(d.v.c, 12); d.parse_field("v", Some("a:world,b:84,c:24")).unwrap(); assert_eq!(d.v.a, "world"); assert_eq!(d.v.b, 84); assert_eq!(d.v.c, 24); assert_eq!( d.parse_field("v", Some("a:hello")), Err(ArgError::InvalidValue) ); } #[derive(Clone, PartialEq, Debug, Default)] pub enum Tu { #[default] None, A(u8), B(u8), C, D(String), } impl ConfigField for Tu { 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 (tag, rest) = split_tagged_union(v); *self = match tag { "a" => { let mut x = 0u8; x.parse_cli(Some(rest))?; Tu::A(x) } "b" => { let mut x = 0u8; x.parse_cli(Some(rest))?; Tu::B(x) } "c" => { if !rest.is_empty() { return Err(ArgError::InvalidValue); } Tu::C } "d" => Tu::D(rest.to_string()), _ => return Err(ArgError::InvalidValue), }; Ok(()) } fn format_entry(&self, _f: &mut EntryFormatter<'_>) -> fmt::Result { Ok(()) } } #[test] fn parse_into_field_tagged_union() { one_field!(S, Tu, Tu::None); let mut d = S::raw_default(); d.parse_field("v", Some("a:1")).unwrap(); assert_eq!(d.v, Tu::A(1)); d.parse_field("v", Some("b:2")).unwrap(); assert_eq!(d.v, Tu::B(2)); d.parse_field("v", Some("c")).unwrap(); assert_eq!(d.v, Tu::C); d.parse_field("v", Some("d:hello")).unwrap(); assert_eq!(d.v, Tu::D("hello".into())); } #[test] fn parse_into_field_tagged_union_unknown_field() { one_field!(S, Tu, Tu::None); let mut d = S::raw_default(); assert_eq!(d.parse_field("v", Some("x:1")), Err(ArgError::InvalidValue)); } #[test] fn parse_into_field_tagged_union_invalid_field_value() { one_field!(S, Tu, Tu::None); let mut d = S::raw_default(); assert_eq!( d.parse_field("v", Some("a:hello")), Err(ArgError::InvalidValue) ); } #[test] fn parse_into_field_tagged_union_missing_tag() { one_field!(S, Tu, Tu::None); let mut d = S::raw_default(); assert_eq!(d.parse_field("v", Some("a")), Err(ArgError::InvalidValue)); assert_eq!(d.parse_field("v", Some(":a")), Err(ArgError::InvalidValue)); } #[test] fn args_iterator() { let child = split_args("--what +list-things --a=42").into_iter(); let mut iter = ArgsIterator::new(child); assert_eq!(iter.next().unwrap(), b"--what"); assert_eq!(iter.next().unwrap(), b"--a=42"); assert_eq!(iter.next(), None); assert_eq!(iter.next(), None); } fn line_iter(s: &str) -> LineIterator>> { LineIterator::new(Cursor::new(s.as_bytes().to_vec())) } #[test] fn line_iterator() { let mut iter = line_iter( "A\nB=42\nC\n\n# A comment\nD\n\n # An indented comment\n E\n\n# A quoted string with whitespace\nF= \"value \"", ); assert_eq!(iter.next().unwrap(), b"--A"); assert_eq!(iter.next().unwrap(), b"--B=42"); assert_eq!(iter.next().unwrap(), b"--C"); assert_eq!(iter.next().unwrap(), b"--D"); assert_eq!(iter.next().unwrap(), b"--E"); assert_eq!(iter.next().unwrap(), b"--F=value "); assert_eq!(iter.next(), None); assert_eq!(iter.next(), None); } #[test] fn line_iterator_end_in_newline() { let mut iter = line_iter("A\n\n"); assert_eq!(iter.next().unwrap(), b"--A"); assert_eq!(iter.next(), None); assert_eq!(iter.next(), None); } #[test] fn line_iterator_spaces_around_equals() { let mut iter = line_iter("A = B\n\n"); assert_eq!(iter.next().unwrap(), b"--A=B"); assert_eq!(iter.next(), None); assert_eq!(iter.next(), None); } #[test] fn line_iterator_no_value() { let mut iter = line_iter("A = \n\n"); assert_eq!(iter.next().unwrap(), b"--A="); assert_eq!(iter.next(), None); } #[test] fn line_iterator_with_crlf_line_endings() { let mut iter = line_iter("A\r\nB = C\r\n"); assert_eq!(iter.next().unwrap(), b"--A"); assert_eq!(iter.next().unwrap(), b"--B=C"); assert_eq!(iter.next(), None); assert_eq!(iter.next(), None); } #[test] fn line_iterator_with_buffered_reader() { let r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec())); let mut iter = LineIterator::new(r); assert_eq!(iter.next().unwrap(), b"--A"); assert_eq!(iter.next().unwrap(), b"--B=C"); assert_eq!(iter.next(), None); assert_eq!(iter.next(), None); } #[test] fn line_iterator_with_buffered_and_primed_reader() { use std::io::BufRead; let mut r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec())); r.fill_buf().unwrap(); let mut iter = LineIterator::new(r); assert_eq!(iter.next().unwrap(), b"--A"); assert_eq!(iter.next().unwrap(), b"--B=C"); assert_eq!(iter.next(), None); assert_eq!(iter.next(), None); } #[test] 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] fn zig_string_literal_decode() { assert_eq!( parse_zig_string_literal("\"a\\n\\x41\\u{1F47B}\"").unwrap(), "a\nA\u{1F47B}" ); assert_eq!(parse_zig_string_literal("\"\\q\""), None); } #[test] fn zig_string_literal_keeps_raw_bytes() { // `\xFF` is not valid UTF-8 and must stay the single byte 0xFF. assert_eq!( parse_zig_string_literal_bytes("\"a\\xFFb\"").unwrap(), vec![b'a', 0xff, b'b'] ); assert!(parse_zig_string_literal("\"a\\xFFb\"").is_none()); // Valid multi-byte sequences spelled with escapes still decode. assert_eq!( parse_zig_string_literal("\"\\xC3\\xA9\"").unwrap(), "\u{e9}" ); // Raw (unescaped) invalid input bytes survive literal parsing. let raw = b"\"x\xfey\""; assert_eq!( parse_zig_string_literal_bytes(raw).unwrap(), vec![b'x', 0xfe, b'y'] ); } #[test] fn line_iterator_keeps_invalid_utf8_bytes() { let data: &[u8] = b"title = a\xffb\n"; let mut it = LineIterator::new(data); let line = it.next().unwrap(); assert_eq!(line, b"--title=a\xffb".to_vec()); } }