//! Rust format strings (`println!`, `format!`) -> Nim string expressions. //! //! Only the subset that is reproduced exactly is accepted. An unrecognised //! format spec is an error, never a best-effort guess: `{:>8.3}` silently //! rendered as `{}` would produce plausible-looking wrong output, which is the //! failure mode this project exists to avoid. /// One piece of a parsed format string. #[derive(Debug, PartialEq)] pub enum Piece { Lit(String), Arg { r#ref: Ref, spec: Spec }, } #[derive(Debug, PartialEq)] pub enum Ref { /// `{}` — consumes the next positional argument. Next, /// `{0}` — an explicit index. Index(usize), /// `{name}` — an inline captured identifier. Named(String), } #[derive(Debug, PartialEq, Default)] pub struct Spec { pub debug: bool, /// `x`, `X`, `b`, `o` — `None` for Display/Debug. pub radix: Option, pub width: usize, pub zero: bool, } pub fn parse(s: &str) -> Result, String> { let mut out = Vec::new(); let mut lit = String::new(); let mut it = s.chars().peekable(); while let Some(c) = it.next() { match c { '{' if it.peek() == Some(&'{') => { it.next(); lit.push('{'); } '}' if it.peek() == Some(&'}') => { it.next(); lit.push('}'); } '}' => return Err("unmatched `}` in format string".into()), '{' => { if !lit.is_empty() { out.push(Piece::Lit(std::mem::take(&mut lit))); } let mut body = String::new(); let mut closed = false; for c in it.by_ref() { if c == '}' { closed = true; break; } body.push(c); } if !closed { return Err("unmatched `{` in format string".into()); } let (name, spec) = match body.split_once(':') { Some((n, s)) => (n, parse_spec(s)?), None => (body.as_str(), Spec::default()), }; let r#ref = if name.is_empty() { Ref::Next } else if let Ok(i) = name.parse::() { Ref::Index(i) } else if name.chars().all(|c| c.is_alphanumeric() || c == '_') { Ref::Named(name.to_string()) } else { return Err(format!("unsupported format argument `{name}`")); }; out.push(Piece::Arg { r#ref, spec }); } _ => lit.push(c), } } if !lit.is_empty() { out.push(Piece::Lit(lit)); } Ok(out) } fn parse_spec(s: &str) -> Result { let mut spec = Spec::default(); let mut rest = s; match rest.chars().last() { Some('?') => { spec.debug = true; rest = &rest[..rest.len() - 1]; } Some(r @ ('x' | 'X' | 'b' | 'o')) => { spec.radix = Some(r); rest = &rest[..rest.len() - 1]; } _ => {} } if let Some(r) = rest.strip_prefix('0') { spec.zero = true; rest = r; } if !rest.is_empty() { spec.width = rest .parse::() .map_err(|_| format!("unsupported format spec `:{s}` (precision, alignment and fill are not implemented)"))?; } Ok(spec) } /// Build the Nim expression for one argument, given its already-lowered value. pub fn render_arg(value: &str, spec: &Spec) -> String { let core = match spec.radix { Some(r) => format!( "rsRadix({}, {}, {})", value, match r { 'x' | 'X' => 16, 'b' => 2, _ => 8, }, r == 'X' ), None if spec.debug => format!("rsDebug({value})"), None => format!("rsDisplay({value})"), }; if spec.width > 0 { format!("rsPad({}, {}, {})", core, spec.width, spec.zero) } else { core } } /// Nim string literal with Rust's escaping rules applied to the bytes we emit. pub fn nim_str(s: &str) -> String { let mut out = String::from("\""); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\t' => out.push_str("\\t"), '\r' => out.push_str("\\r"), c => out.push(c), } } out.push('"'); out } #[cfg(test)] mod tests { use super::*; #[test] fn braces_and_specs() { assert_eq!(parse("a{{b").unwrap(), vec![Piece::Lit("a{b".into())]); assert_eq!( parse("{:02x}").unwrap(), vec![Piece::Arg { r#ref: Ref::Next, spec: Spec { debug: false, radix: Some('x'), width: 2, zero: true } }] ); assert_eq!( parse("{n:?}").unwrap(), vec![Piece::Arg { r#ref: Ref::Named("n".into()), spec: Spec { debug: true, ..Spec::default() } }] ); } #[test] fn unsupported_specs_are_rejected_not_guessed() { assert!(parse("{:>8}").is_err()); assert!(parse("{:.3}").is_err()); assert!(parse("{").is_err()); } }