| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 17h ago | 1 | //! Rust format strings (`println!`, `format!`) -> Nim string expressions. |
| 2 | //! |
| 3 | //! Only the subset that is reproduced exactly is accepted. An unrecognised |
| 4 | //! format spec is an error, never a best-effort guess: `{:>8.3}` silently |
| 5 | //! rendered as `{}` would produce plausible-looking wrong output, which is the |
| 6 | //! failure mode this project exists to avoid. |
| 7 | |
| 8 | /// One piece of a parsed format string. |
| 9 | #[derive(Debug, PartialEq)] |
| 10 | pub enum Piece { |
| 11 | Lit(String), |
| 12 | Arg { r#ref: Ref, spec: Spec }, |
| 13 | } |
| 14 | |
| 15 | #[derive(Debug, PartialEq)] |
| 16 | pub enum Ref { |
| 17 | /// `{}` — consumes the next positional argument. |
| 18 | Next, |
| 19 | /// `{0}` — an explicit index. |
| 20 | Index(usize), |
| 21 | /// `{name}` — an inline captured identifier. |
| 22 | Named(String), |
| 23 | } |
| 24 | |
| 25 | #[derive(Debug, PartialEq, Default)] |
| 26 | pub struct Spec { |
| 27 | pub debug: bool, |
| 28 | /// `x`, `X`, `b`, `o` — `None` for Display/Debug. |
| 29 | pub radix: Option<char>, |
| 30 | pub width: usize, |
| 31 | pub zero: bool, |
| 32 | } |
| 33 | |
| 34 | pub fn parse(s: &str) -> Result<Vec<Piece>, String> { |
| 35 | let mut out = Vec::new(); |
| 36 | let mut lit = String::new(); |
| 37 | let mut it = s.chars().peekable(); |
| 38 | |
| 39 | while let Some(c) = it.next() { |
| 40 | match c { |
| 41 | '{' if it.peek() == Some(&'{') => { |
| 42 | it.next(); |
| 43 | lit.push('{'); |
| 44 | } |
| 45 | '}' if it.peek() == Some(&'}') => { |
| 46 | it.next(); |
| 47 | lit.push('}'); |
| 48 | } |
| 49 | '}' => return Err("unmatched `}` in format string".into()), |
| 50 | '{' => { |
| 51 | if !lit.is_empty() { |
| 52 | out.push(Piece::Lit(std::mem::take(&mut lit))); |
| 53 | } |
| 54 | let mut body = String::new(); |
| 55 | let mut closed = false; |
| 56 | for c in it.by_ref() { |
| 57 | if c == '}' { |
| 58 | closed = true; |
| 59 | break; |
| 60 | } |
| 61 | body.push(c); |
| 62 | } |
| 63 | if !closed { |
| 64 | return Err("unmatched `{` in format string".into()); |
| 65 | } |
| 66 | let (name, spec) = match body.split_once(':') { |
| 67 | Some((n, s)) => (n, parse_spec(s)?), |
| 68 | None => (body.as_str(), Spec::default()), |
| 69 | }; |
| 70 | let r#ref = if name.is_empty() { |
| 71 | Ref::Next |
| 72 | } else if let Ok(i) = name.parse::<usize>() { |
| 73 | Ref::Index(i) |
| 74 | } else if name.chars().all(|c| c.is_alphanumeric() || c == '_') { |
| 75 | Ref::Named(name.to_string()) |
| 76 | } else { |
| 77 | return Err(format!("unsupported format argument `{name}`")); |
| 78 | }; |
| 79 | out.push(Piece::Arg { r#ref, spec }); |
| 80 | } |
| 81 | _ => lit.push(c), |
| 82 | } |
| 83 | } |
| 84 | if !lit.is_empty() { |
| 85 | out.push(Piece::Lit(lit)); |
| 86 | } |
| 87 | Ok(out) |
| 88 | } |
| 89 | |
| 90 | fn parse_spec(s: &str) -> Result<Spec, String> { |
| 91 | let mut spec = Spec::default(); |
| 92 | let mut rest = s; |
| 93 | |
| 94 | match rest.chars().last() { |
| 95 | Some('?') => { |
| 96 | spec.debug = true; |
| 97 | rest = &rest[..rest.len() - 1]; |
| 98 | } |
| 99 | Some(r @ ('x' | 'X' | 'b' | 'o')) => { |
| 100 | spec.radix = Some(r); |
| 101 | rest = &rest[..rest.len() - 1]; |
| 102 | } |
| 103 | _ => {} |
| 104 | } |
| 105 | |
| 106 | if let Some(r) = rest.strip_prefix('0') { |
| 107 | spec.zero = true; |
| 108 | rest = r; |
| 109 | } |
| 110 | if !rest.is_empty() { |
| 111 | spec.width = rest |
| 112 | .parse::<usize>() |
| 113 | .map_err(|_| format!("unsupported format spec `:{s}` (precision, alignment and fill are not implemented)"))?; |
| 114 | } |
| 115 | Ok(spec) |
| 116 | } |
| 117 | |
| 118 | /// Build the Nim expression for one argument, given its already-lowered value. |
| Add display.rs and the alloc half: all of base16ct now goes through afb2a6e nandithebull 15h ago | 119 | /// |
| 120 | /// `integer` says whether the value is one of Nim's integer types. `{:x}` on |
| 121 | /// an integer formats its two's-complement bit pattern; on anything else it is |
| 122 | /// a call to that type's own `LowerHex`/`UpperHex` impl, which is a different |
| 123 | /// operation and a different proc. |
| 124 | pub fn render_arg(value: &str, spec: &Spec, integer: bool) -> String { |
| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 17h ago | 125 | let core = match spec.radix { |
| Add display.rs and the alloc half: all of base16ct now goes through afb2a6e nandithebull 15h ago | 126 | Some(r) if integer => format!( |
| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 17h ago | 127 | "rsRadix({}, {}, {})", |
| 128 | value, |
| 129 | match r { |
| 130 | 'x' | 'X' => 16, |
| 131 | 'b' => 2, |
| 132 | _ => 8, |
| 133 | }, |
| 134 | r == 'X' |
| 135 | ), |
| Add display.rs and the alloc half: all of base16ct now goes through afb2a6e nandithebull 15h ago | 136 | Some(r) => format!( |
| 137 | "{}({})", |
| 138 | match r { |
| 139 | 'x' => "rsLowerHex", |
| 140 | 'X' => "rsUpperHex", |
| 141 | 'b' => "rsBinary", |
| 142 | _ => "rsOctal", |
| 143 | }, |
| 144 | value |
| 145 | ), |
| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 17h ago | 146 | None if spec.debug => format!("rsDebug({value})"), |
| 147 | None => format!("rsDisplay({value})"), |
| 148 | }; |
| 149 | if spec.width > 0 { |
| 150 | format!("rsPad({}, {}, {})", core, spec.width, spec.zero) |
| 151 | } else { |
| 152 | core |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// Nim string literal with Rust's escaping rules applied to the bytes we emit. |
| 157 | pub fn nim_str(s: &str) -> String { |
| 158 | let mut out = String::from("\""); |
| 159 | for c in s.chars() { |
| 160 | match c { |
| 161 | '"' => out.push_str("\\\""), |
| 162 | '\\' => out.push_str("\\\\"), |
| 163 | '\n' => out.push_str("\\n"), |
| 164 | '\t' => out.push_str("\\t"), |
| 165 | '\r' => out.push_str("\\r"), |
| 166 | c => out.push(c), |
| 167 | } |
| 168 | } |
| 169 | out.push('"'); |
| 170 | out |
| 171 | } |
| 172 | |
| 173 | #[cfg(test)] |
| 174 | mod tests { |
| 175 | use super::*; |
| 176 | |
| 177 | #[test] |
| 178 | fn braces_and_specs() { |
| 179 | assert_eq!(parse("a{{b").unwrap(), vec![Piece::Lit("a{b".into())]); |
| 180 | assert_eq!( |
| 181 | parse("{:02x}").unwrap(), |
| 182 | vec![Piece::Arg { |
| 183 | r#ref: Ref::Next, |
| 184 | spec: Spec { debug: false, radix: Some('x'), width: 2, zero: true } |
| 185 | }] |
| 186 | ); |
| 187 | assert_eq!( |
| 188 | parse("{n:?}").unwrap(), |
| 189 | vec![Piece::Arg { |
| 190 | r#ref: Ref::Named("n".into()), |
| 191 | spec: Spec { debug: true, ..Spec::default() } |
| 192 | }] |
| 193 | ); |
| 194 | } |
| 195 | |
| 196 | #[test] |
| 197 | fn unsupported_specs_are_rejected_not_guessed() { |
| 198 | assert!(parse("{:>8}").is_err()); |
| 199 | assert!(parse("{:.3}").is_err()); |
| 200 | assert!(parse("{").is_err()); |
| 201 | } |
| 202 | } |