Skip to main content

polydat_core/library/
format.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Printf-style formatting node.
5//!
6//! Takes a format string and N inputs, produces a formatted String.
7//! Uses `{}` placeholders (Rust-style, not C printf-style), with
8//! optional format specifiers.
9//!
10//! Supported specifiers:
11//! - `{}` — default display
12//! - `{:05}` — zero-padded to width 5 (u64)
13//! - `{:.2}` — 2 decimal places (f64)
14//! - `{:x}` — lowercase hex (u64)
15//! - `{:X}` — uppercase hex (u64)
16//! - `{:b}` — binary (u64)
17//! - `{:o}` — octal (u64)
18//!
19//! `printf` takes a `Const<&str>` format string, a `#[poly_const]`
20//! cached `ParsedFormat`, and `&[Value]` variadic wires. The cached
21//! `ParsedFormat` is computed once at construction (in the
22//! `parse_format` setup-fn) so per-eval work is just iterating the
23//! pre-parsed segments.
24
25use crate::ast::Value;
26use crate::derive_support::PolydatSetup;
27
28/// A parsed format segment: either literal text or a placeholder.
29#[derive(Debug, Clone)]
30pub enum Segment {
31    /// Literal text, copied as is.
32    Literal(String),
33    /// A placeholder, formatted from the next argument.
34    Placeholder(FormatSpec),
35}
36
37#[derive(Debug, Clone)]
38/// One placeholder's formatting: which argument, and how to render it.
39pub struct FormatSpec {
40    /// Input index (sequential, 0-based)
41    index: usize,
42    /// Optional width
43    width: Option<usize>,
44    /// Optional precision (decimal places)
45    precision: Option<usize>,
46    /// Fill character for width (default space, '0' for zero-pad)
47    fill: char,
48    /// Conversion: 'd' (decimal, default), 'x' (hex), 'X' (HEX), 'b' (binary), 'o' (octal)
49    conversion: char,
50}
51
52/// Pre-parsed format string cached on the `Printf` node. The
53/// `#[polydat_node]` macro invokes `parse_format` once at
54/// construction; eval reads the segments directly with no
55/// per-call parsing.
56#[derive(Debug, Clone)]
57pub struct ParsedFormat {
58    segments: Vec<Segment>,
59}
60
61impl PolydatSetup for ParsedFormat {}
62
63/// Printf-style N→1 formatting node. Variadic: accepts 0..N wire inputs.
64///
65/// Signature: `printf(format: String, in_0, in_1, ...) -> (String)`
66///
67/// Format string uses Rust-style `{}` placeholders with optional specifiers:
68/// `{:05}` (zero-pad), `{:.2}` (precision), `{:x}` (hex), `{:X}` (HEX),
69/// `{:b}` (binary), `{:o}` (octal). Inputs are matched positionally.
70///
71/// Use for constructing complex formatted strings from multiple Polydat wires:
72/// `printf("user-{:05}-score-{:.1}", id, score)` → "user-00042-score-98.6"
73///
74/// All Value types are accepted at eval time regardless of declared port
75/// types (the variadic slots advertise `PortType::Str` but the body
76/// dispatches on `Value` variants). The format specifier determines how
77/// each value renders.
78///
79/// None propagation through string interpolation (none_semantics.md,
80/// Rule 1): if any REFERENCED input is `Value::None`, the whole result
81/// is `Value::None`. The body itself doesn't materialise this —
82/// the Polydat kernel's Rule 1 guard (engines.rs) emits
83/// `Value::None` on every output for any node whose inputs
84/// include `Value::None` and which doesn't opt into
85/// `accepts_none_inputs`. Printf doesn't opt in, so the kernel
86/// guard fires before this body is invoked at production time.
87///
88/// Rationale: `Value::None` is the canonical "absent" sentinel.
89/// The Polydat Kernel's `lookup` / `get_constant` already treat
90/// None-valued outputs as "not present in this scope" and fall
91/// through to the parent scope. String interpolation keeps that
92/// discipline: an unresolved `{X}` in a source-level string literal compiles
93/// to a `printf` call with the unresolved name's slot, and when
94/// that slot evaluates to None the printf result should likewise
95/// be None so the binding doesn't shadow upstream defaults. The
96/// canonical end-to-end coverage for this lives in
97/// `tests/scope_composition.rs::const_with_unbound_interpolation_*`.
98#[crate::polydat_node(category = Formatting)]
99fn printf(
100    format: Const<&str>,
101    #[poly_const(ParsedFormat::from_format_str, from = format)] parsed: &ParsedFormat,
102    parts: &[polydat::ast::Value],
103) -> String {
104    parsed.render_with(parts.len(), |i| FmtArg::from(&parts[i]))
105}
106
107/// One argument to a format, as the formatter needs it: a scalar by
108/// value, a string by reference, anything else as the `Value` whose
109/// display form is used. The P1 node builds these from its `Value`
110/// inputs so a string argument is formatted without being copied
111/// first.
112pub enum FmtArg<'a> {
113    /// An unsigned integer.
114    U64(u64),
115    /// A float.
116    F64(f64),
117    /// A boolean.
118    Bool(bool),
119    /// A string, by reference.
120    Str(&'a str),
121    /// Any other value, rendered in its display form.
122    Value(Value),
123}
124
125impl<'a> From<&'a Value> for FmtArg<'a> {
126    fn from(v: &'a Value) -> Self {
127        match v {
128            Value::U64(x) => FmtArg::U64(*x),
129            Value::F64(x) => FmtArg::F64(*x),
130            Value::Bool(b) => FmtArg::Bool(*b),
131            Value::Str(s) => FmtArg::Str(s),
132            other => FmtArg::Value(other.clone()),
133        }
134    }
135}
136
137impl ParsedFormat {
138    /// Setup-fn for `#[poly_const(...)]`: parse a format string
139    /// into a list of segments. Called once at node construction;
140    /// the resulting `ParsedFormat` is cached on the struct field
141    /// and borrowed by every eval call.
142    pub fn from_format_str(fmt: &str) -> Self {
143        Self {
144            segments: parse_format(fmt),
145        }
146    }
147
148    /// Render the format over `argc` arguments fetched by index.
149    /// Panics, as the node always has, when a placeholder names an
150    /// argument that was not supplied.
151    pub fn render_with<'a>(&self, argc: usize, arg: impl Fn(usize) -> FmtArg<'a>) -> String {
152        let mut result = String::new();
153        self.render_into(argc, arg, &mut result);
154        result
155    }
156
157    /// Render into any text sink.
158    pub fn render_into<'a, W: std::fmt::Write>(
159        &self,
160        argc: usize,
161        arg: impl Fn(usize) -> FmtArg<'a>,
162        out: &mut W,
163    ) {
164        for seg in &self.segments {
165            match seg {
166                Segment::Literal(s) => {
167                    let _ = out.write_str(s);
168                }
169                Segment::Placeholder(spec) => {
170                    if spec.index >= argc {
171                        panic!(
172                            "printf: format references input #{} but only {argc} wire input(s) supplied",
173                            spec.index,
174                        );
175                    }
176                    let _ = out.write_str(&format_arg(&arg(spec.index), spec));
177                }
178            }
179        }
180    }
181}
182
183fn format_arg(arg: &FmtArg<'_>, spec: &FormatSpec) -> String {
184    match arg {
185        FmtArg::U64(v) => format_u64(*v, spec),
186        FmtArg::F64(v) => format_f64(*v, spec),
187        FmtArg::Bool(v) => v.to_string(),
188        FmtArg::Str(v) => {
189            if let Some(w) = spec.width {
190                format!("{:>width$}", v, width = w)
191            } else {
192                v.to_string()
193            }
194        }
195        // Extension values render through their reflected display form,
196        // the same text `to_display_string` produces, so a Streamer or
197        // Partition interpolates as the author would expect.
198        FmtArg::Value(val @ Value::Ext(_)) => val.to_display_string(),
199        FmtArg::Value(val) => format!("{val:?}"),
200    }
201}
202
203fn format_u64(v: u64, spec: &FormatSpec) -> String {
204    let raw = match spec.conversion {
205        'x' => format!("{v:x}"),
206        'X' => format!("{v:X}"),
207        'b' => format!("{v:b}"),
208        'o' => format!("{v:o}"),
209        _ => v.to_string(),
210    };
211    apply_width(&raw, spec)
212}
213
214fn format_f64(v: f64, spec: &FormatSpec) -> String {
215    let raw = if let Some(prec) = spec.precision {
216        format!("{v:.prec$}")
217    } else {
218        // Bare `{}` for f64 uses Debug formatting so whole-number
219        // floats render as `1.0` instead of `1`, matching
220        // `Value::F64::to_display_string`. Authors who want
221        // integer-style output for whole floats specify a
222        // precision (`{:.0}`) or convert via `format_u64`.
223        format!("{v:?}")
224    };
225    apply_width(&raw, spec)
226}
227
228fn apply_width(s: &str, spec: &FormatSpec) -> String {
229    if let Some(w) = spec.width {
230        if s.len() < w {
231            let pad = w - s.len();
232            let fill = spec.fill;
233            format!("{}{s}", std::iter::repeat_n(fill, pad).collect::<String>())
234        } else {
235            s.to_string()
236        }
237    } else {
238        s.to_string()
239    }
240}
241
242fn parse_format(fmt: &str) -> Vec<Segment> {
243    let mut segments = Vec::new();
244    let mut literal = String::new();
245    let chars: Vec<char> = fmt.chars().collect();
246    let mut i = 0;
247    let mut placeholder_idx = 0;
248
249    while i < chars.len() {
250        if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '{' {
251            literal.push('{');
252            i += 2;
253        } else if chars[i] == '{' {
254            if !literal.is_empty() {
255                segments.push(Segment::Literal(std::mem::take(&mut literal)));
256            }
257            // Find closing }
258            let start = i + 1;
259            while i < chars.len() && chars[i] != '}' {
260                i += 1;
261            }
262            let spec_str: String = chars[start..i].iter().collect();
263            let spec = parse_spec(&spec_str, placeholder_idx);
264            segments.push(Segment::Placeholder(spec));
265            placeholder_idx += 1;
266            i += 1; // skip }
267        } else if chars[i] == '}' && i + 1 < chars.len() && chars[i + 1] == '}' {
268            literal.push('}');
269            i += 2;
270        } else {
271            literal.push(chars[i]);
272            i += 1;
273        }
274    }
275
276    if !literal.is_empty() {
277        segments.push(Segment::Literal(literal));
278    }
279
280    segments
281}
282
283fn parse_spec(spec: &str, index: usize) -> FormatSpec {
284    let mut result = FormatSpec {
285        index,
286        width: None,
287        precision: None,
288        fill: ' ',
289        conversion: 'd',
290    };
291
292    if spec.is_empty() {
293        return result;
294    }
295
296    // Strip leading ':'
297    let spec = spec.strip_prefix(':').unwrap_or(spec);
298    if spec.is_empty() {
299        return result;
300    }
301
302    let chars: Vec<char> = spec.chars().collect();
303    let mut pos = 0;
304
305    // Check for zero-fill
306    if pos < chars.len()
307        && chars[pos] == '0'
308        && pos + 1 < chars.len()
309        && chars[pos + 1].is_ascii_digit()
310    {
311        result.fill = '0';
312        pos += 1;
313    }
314
315    // Width
316    let width_start = pos;
317    while pos < chars.len() && chars[pos].is_ascii_digit() {
318        pos += 1;
319    }
320    if pos > width_start {
321        let w: String = chars[width_start..pos].iter().collect();
322        result.width = Some(w.parse().unwrap());
323    }
324
325    // Precision
326    if pos < chars.len() && chars[pos] == '.' {
327        pos += 1;
328        let prec_start = pos;
329        while pos < chars.len() && chars[pos].is_ascii_digit() {
330            pos += 1;
331        }
332        if pos > prec_start {
333            let p: String = chars[prec_start..pos].iter().collect();
334            result.precision = Some(p.parse().unwrap());
335        }
336    }
337
338    // Conversion
339    if pos < chars.len() {
340        result.conversion = chars[pos];
341    }
342
343    result
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::ast::PolydatNode;
350
351    #[test]
352    fn printf_simple() {
353        let node = Printf::new("hello {}".to_string(), 1);
354        let mut out = [Value::None];
355        node.eval(&[Value::U64(42)], &mut out);
356        assert_eq!(out[0].as_str(), "hello 42");
357    }
358
359    #[test]
360    fn printf_multiple() {
361        let node = Printf::new("{} + {} = {}".to_string(), 3);
362        let mut out = [Value::None];
363        node.eval(&[Value::U64(1), Value::U64(2), Value::U64(3)], &mut out);
364        assert_eq!(out[0].as_str(), "1 + 2 = 3");
365    }
366
367    #[test]
368    fn printf_zero_pad() {
369        let node = Printf::new("{:05}".to_string(), 1);
370        let mut out = [Value::None];
371        node.eval(&[Value::U64(42)], &mut out);
372        assert_eq!(out[0].as_str(), "00042");
373    }
374
375    #[test]
376    fn printf_hex() {
377        let node = Printf::new("{:x}".to_string(), 1);
378        let mut out = [Value::None];
379        node.eval(&[Value::U64(255)], &mut out);
380        assert_eq!(out[0].as_str(), "ff");
381    }
382
383    #[test]
384    fn printf_hex_upper() {
385        let node = Printf::new("{:X}".to_string(), 1);
386        let mut out = [Value::None];
387        node.eval(&[Value::U64(255)], &mut out);
388        assert_eq!(out[0].as_str(), "FF");
389    }
390
391    #[test]
392    fn printf_precision() {
393        let node = Printf::new("{:.2}".to_string(), 1);
394        let mut out = [Value::None];
395        node.eval(&[Value::F64(3.14159)], &mut out);
396        assert_eq!(out[0].as_str(), "3.14");
397    }
398
399    #[test]
400    fn printf_mixed() {
401        let node = Printf::new("id={:05} val={:.1}".to_string(), 2);
402        let mut out = [Value::None];
403        node.eval(&[Value::U64(7), Value::F64(98.6)], &mut out);
404        assert_eq!(out[0].as_str(), "id=00007 val=98.6");
405    }
406
407    #[test]
408    fn printf_literal_braces() {
409        let node = Printf::new("{{escaped}} {}".to_string(), 1);
410        let mut out = [Value::None];
411        node.eval(&[Value::U64(1)], &mut out);
412        assert_eq!(out[0].as_str(), "{escaped} 1");
413    }
414
415    #[test]
416    fn printf_no_placeholders() {
417        let node = Printf::new("just text".to_string(), 0);
418        let mut out = [Value::None];
419        node.eval(&[], &mut out);
420        assert_eq!(out[0].as_str(), "just text");
421    }
422
423    #[test]
424    fn printf_string_input() {
425        let node = Printf::new("hello {}".to_string(), 1);
426        let mut out = [Value::None];
427        node.eval(&[Value::Str("world".into())], &mut out);
428        assert_eq!(out[0].as_str(), "hello world");
429    }
430
431    // ────────────────────────────────────────────────────────
432    // None propagation (none_semantics.md, Rule 1)
433    //
434    // String interpolation evaluates to Value::None when any
435    // referenced input is Value::None. The canonical
436    // None-propagation surface is the Polydat kernel's
437    // Rule 1 guard (engines.rs): any
438    // node whose inputs include Value::None and which doesn't
439    // override `accepts_none_inputs` emits None on every output
440    // BEFORE the body is invoked. The body therefore never
441    // observes a None-tainted `parts` slice at production time.
442    //
443    // End-to-end coverage of the kernel-level None-propagation
444    // through printf lives in `tests/scope_composition.rs`
445    // (`const_with_unbound_interpolation_falls_through_to_outer`).
446    // There are no direct-eval unit tests for a body-side check:
447    // the body never sees a None input.
448    // ────────────────────────────────────────────────────────
449
450    #[test]
451    fn printf_all_present_unchanged() {
452        // Sanity: a multi-arg format with no None inputs. This
453        // is the regression guard for the overwhelming common
454        // case the body actually handles.
455        let node = Printf::new("a={} b={}".to_string(), 2);
456        let mut out = [Value::None];
457        node.eval(&[Value::U64(1), Value::U64(2)], &mut out);
458        assert_eq!(out[0].as_str(), "a=1 b=2");
459    }
460
461    #[test]
462    fn printf_no_placeholders_still_renders() {
463        // Edge: a format with no placeholders. The result is
464        // the literal string.
465        let node = Printf::new("static text".to_string(), 0);
466        let mut out = [Value::None];
467        node.eval(&[], &mut out);
468        assert_eq!(out[0].as_str(), "static text");
469    }
470}