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/// SRD-73 follow-up: None propagation through string interpolation.
80/// If any REFERENCED input is `Value::None`, the whole result is
81/// `Value::None`. The body itself doesn't materialise this —
82/// the Polydat kernel's SRD-74 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 is the
92/// surface where that discipline was being silently broken — an
93/// unresolved `{X}` in a source-level string literal compiles
94/// to a `printf` call with the unresolved name's slot, and when
95/// that slot evaluates to None the printf result should likewise
96/// be None so the binding doesn't shadow upstream defaults. The
97/// canonical end-to-end coverage for this lives in
98/// `tests/scope_composition.rs::const_with_unbound_interpolation_*`.
99#[crate::polydat_node(category = Formatting)]
100fn printf(
101    format: Const<&str>,
102    #[poly_const(ParsedFormat::from_format_str, from = format)] parsed: &ParsedFormat,
103    parts: &[polydat::ast::Value],
104) -> String {
105    parsed.render_with(parts.len(), |i| FmtArg::from(&parts[i]))
106}
107
108/// One argument to a format, as the formatter needs it: a scalar by
109/// value, a string by reference, anything else as the `Value` whose
110/// display form is used. The P1 node builds these from its `Value`
111/// inputs so a string argument is formatted without being copied
112/// first.
113pub enum FmtArg<'a> {
114    /// An unsigned integer.
115    U64(u64),
116    /// A float.
117    F64(f64),
118    /// A boolean.
119    Bool(bool),
120    /// A string, by reference.
121    Str(&'a str),
122    /// Any other value, rendered in its display form.
123    Value(Value),
124}
125
126impl<'a> From<&'a Value> for FmtArg<'a> {
127    fn from(v: &'a Value) -> Self {
128        match v {
129            Value::U64(x) => FmtArg::U64(*x),
130            Value::F64(x) => FmtArg::F64(*x),
131            Value::Bool(b) => FmtArg::Bool(*b),
132            Value::Str(s) => FmtArg::Str(s),
133            other => FmtArg::Value(other.clone()),
134        }
135    }
136}
137
138impl ParsedFormat {
139    /// Setup-fn for `#[poly_const(...)]`: parse a format string
140    /// into a list of segments. Called once at node construction;
141    /// the resulting `ParsedFormat` is cached on the struct field
142    /// and borrowed by every eval call.
143    pub fn from_format_str(fmt: &str) -> Self {
144        Self {
145            segments: parse_format(fmt),
146        }
147    }
148
149    /// Render the format over `argc` arguments fetched by index.
150    /// Panics, as the node always has, when a placeholder names an
151    /// argument that was not supplied.
152    pub fn render_with<'a>(&self, argc: usize, arg: impl Fn(usize) -> FmtArg<'a>) -> String {
153        let mut result = String::new();
154        self.render_into(argc, arg, &mut result);
155        result
156    }
157
158    /// Render into any text sink.
159    pub fn render_into<'a, W: std::fmt::Write>(
160        &self,
161        argc: usize,
162        arg: impl Fn(usize) -> FmtArg<'a>,
163        out: &mut W,
164    ) {
165        for seg in &self.segments {
166            match seg {
167                Segment::Literal(s) => {
168                    let _ = out.write_str(s);
169                }
170                Segment::Placeholder(spec) => {
171                    if spec.index >= argc {
172                        panic!(
173                            "printf: format references input #{} but only {argc} wire input(s) supplied",
174                            spec.index,
175                        );
176                    }
177                    let _ = out.write_str(&format_arg(&arg(spec.index), spec));
178                }
179            }
180        }
181    }
182}
183
184fn format_arg(arg: &FmtArg<'_>, spec: &FormatSpec) -> String {
185    match arg {
186        FmtArg::U64(v) => format_u64(*v, spec),
187        FmtArg::F64(v) => format_f64(*v, spec),
188        FmtArg::Bool(v) => v.to_string(),
189        FmtArg::Str(v) => {
190            if let Some(w) = spec.width {
191                format!("{:>width$}", v, width = w)
192            } else {
193                v.to_string()
194            }
195        }
196        // Extension values render through their reflected display form,
197        // the same text `to_display_string` produces, so a Streamer or
198        // Partition interpolates as the author would expect.
199        FmtArg::Value(val @ Value::Ext(_)) => val.to_display_string(),
200        FmtArg::Value(val) => format!("{val:?}"),
201    }
202}
203
204fn format_u64(v: u64, spec: &FormatSpec) -> String {
205    let raw = match spec.conversion {
206        'x' => format!("{v:x}"),
207        'X' => format!("{v:X}"),
208        'b' => format!("{v:b}"),
209        'o' => format!("{v:o}"),
210        _ => v.to_string(),
211    };
212    apply_width(&raw, spec)
213}
214
215fn format_f64(v: f64, spec: &FormatSpec) -> String {
216    let raw = if let Some(prec) = spec.precision {
217        format!("{v:.prec$}")
218    } else {
219        // Bare `{}` for f64 uses Debug formatting so whole-number
220        // floats render as `1.0` instead of `1`, matching
221        // `Value::F64::to_display_string`. Authors who want
222        // integer-style output for whole floats specify a
223        // precision (`{:.0}`) or convert via `format_u64`.
224        format!("{v:?}")
225    };
226    apply_width(&raw, spec)
227}
228
229fn apply_width(s: &str, spec: &FormatSpec) -> String {
230    if let Some(w) = spec.width {
231        if s.len() < w {
232            let pad = w - s.len();
233            let fill = spec.fill;
234            format!("{}{s}", std::iter::repeat_n(fill, pad).collect::<String>())
235        } else {
236            s.to_string()
237        }
238    } else {
239        s.to_string()
240    }
241}
242
243fn parse_format(fmt: &str) -> Vec<Segment> {
244    let mut segments = Vec::new();
245    let mut literal = String::new();
246    let chars: Vec<char> = fmt.chars().collect();
247    let mut i = 0;
248    let mut placeholder_idx = 0;
249
250    while i < chars.len() {
251        if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '{' {
252            literal.push('{');
253            i += 2;
254        } else if chars[i] == '{' {
255            if !literal.is_empty() {
256                segments.push(Segment::Literal(std::mem::take(&mut literal)));
257            }
258            // Find closing }
259            let start = i + 1;
260            while i < chars.len() && chars[i] != '}' {
261                i += 1;
262            }
263            let spec_str: String = chars[start..i].iter().collect();
264            let spec = parse_spec(&spec_str, placeholder_idx);
265            segments.push(Segment::Placeholder(spec));
266            placeholder_idx += 1;
267            i += 1; // skip }
268        } else if chars[i] == '}' && i + 1 < chars.len() && chars[i + 1] == '}' {
269            literal.push('}');
270            i += 2;
271        } else {
272            literal.push(chars[i]);
273            i += 1;
274        }
275    }
276
277    if !literal.is_empty() {
278        segments.push(Segment::Literal(literal));
279    }
280
281    segments
282}
283
284fn parse_spec(spec: &str, index: usize) -> FormatSpec {
285    let mut result = FormatSpec {
286        index,
287        width: None,
288        precision: None,
289        fill: ' ',
290        conversion: 'd',
291    };
292
293    if spec.is_empty() {
294        return result;
295    }
296
297    // Strip leading ':'
298    let spec = spec.strip_prefix(':').unwrap_or(spec);
299    if spec.is_empty() {
300        return result;
301    }
302
303    let chars: Vec<char> = spec.chars().collect();
304    let mut pos = 0;
305
306    // Check for zero-fill
307    if pos < chars.len()
308        && chars[pos] == '0'
309        && pos + 1 < chars.len()
310        && chars[pos + 1].is_ascii_digit()
311    {
312        result.fill = '0';
313        pos += 1;
314    }
315
316    // Width
317    let width_start = pos;
318    while pos < chars.len() && chars[pos].is_ascii_digit() {
319        pos += 1;
320    }
321    if pos > width_start {
322        let w: String = chars[width_start..pos].iter().collect();
323        result.width = Some(w.parse().unwrap());
324    }
325
326    // Precision
327    if pos < chars.len() && chars[pos] == '.' {
328        pos += 1;
329        let prec_start = pos;
330        while pos < chars.len() && chars[pos].is_ascii_digit() {
331            pos += 1;
332        }
333        if pos > prec_start {
334            let p: String = chars[prec_start..pos].iter().collect();
335            result.precision = Some(p.parse().unwrap());
336        }
337    }
338
339    // Conversion
340    if pos < chars.len() {
341        result.conversion = chars[pos];
342    }
343
344    result
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::ast::PolydatNode;
351
352    #[test]
353    fn printf_simple() {
354        let node = Printf::new("hello {}".to_string(), 1);
355        let mut out = [Value::None];
356        node.eval(&[Value::U64(42)], &mut out);
357        assert_eq!(out[0].as_str(), "hello 42");
358    }
359
360    #[test]
361    fn printf_multiple() {
362        let node = Printf::new("{} + {} = {}".to_string(), 3);
363        let mut out = [Value::None];
364        node.eval(&[Value::U64(1), Value::U64(2), Value::U64(3)], &mut out);
365        assert_eq!(out[0].as_str(), "1 + 2 = 3");
366    }
367
368    #[test]
369    fn printf_zero_pad() {
370        let node = Printf::new("{:05}".to_string(), 1);
371        let mut out = [Value::None];
372        node.eval(&[Value::U64(42)], &mut out);
373        assert_eq!(out[0].as_str(), "00042");
374    }
375
376    #[test]
377    fn printf_hex() {
378        let node = Printf::new("{:x}".to_string(), 1);
379        let mut out = [Value::None];
380        node.eval(&[Value::U64(255)], &mut out);
381        assert_eq!(out[0].as_str(), "ff");
382    }
383
384    #[test]
385    fn printf_hex_upper() {
386        let node = Printf::new("{:X}".to_string(), 1);
387        let mut out = [Value::None];
388        node.eval(&[Value::U64(255)], &mut out);
389        assert_eq!(out[0].as_str(), "FF");
390    }
391
392    #[test]
393    fn printf_precision() {
394        let node = Printf::new("{:.2}".to_string(), 1);
395        let mut out = [Value::None];
396        node.eval(&[Value::F64(3.14159)], &mut out);
397        assert_eq!(out[0].as_str(), "3.14");
398    }
399
400    #[test]
401    fn printf_mixed() {
402        let node = Printf::new("id={:05} val={:.1}".to_string(), 2);
403        let mut out = [Value::None];
404        node.eval(&[Value::U64(7), Value::F64(98.6)], &mut out);
405        assert_eq!(out[0].as_str(), "id=00007 val=98.6");
406    }
407
408    #[test]
409    fn printf_literal_braces() {
410        let node = Printf::new("{{escaped}} {}".to_string(), 1);
411        let mut out = [Value::None];
412        node.eval(&[Value::U64(1)], &mut out);
413        assert_eq!(out[0].as_str(), "{escaped} 1");
414    }
415
416    #[test]
417    fn printf_no_placeholders() {
418        let node = Printf::new("just text".to_string(), 0);
419        let mut out = [Value::None];
420        node.eval(&[], &mut out);
421        assert_eq!(out[0].as_str(), "just text");
422    }
423
424    #[test]
425    fn printf_string_input() {
426        let node = Printf::new("hello {}".to_string(), 1);
427        let mut out = [Value::None];
428        node.eval(&[Value::Str("world".into())], &mut out);
429        assert_eq!(out[0].as_str(), "hello world");
430    }
431
432    // ────────────────────────────────────────────────────────
433    // None propagation (SRD-73 follow-up)
434    //
435    // String interpolation evaluates to Value::None when any
436    // referenced input is Value::None. The canonical
437    // None-propagation surface is the Polydat kernel's SRD-74
438    // Rule 1 guard (engines.rs): any
439    // node whose inputs include Value::None and which doesn't
440    // override `accepts_none_inputs` emits None on every output
441    // BEFORE the body is invoked. The body therefore never
442    // observes a None-tainted `parts` slice at production time.
443    //
444    // End-to-end coverage of the kernel-level None-propagation
445    // through printf lives in `tests/scope_composition.rs`
446    // (`const_with_unbound_interpolation_falls_through_to_outer`).
447    // There are no direct-eval unit tests for a body-side check:
448    // the body never sees a None input.
449    // ────────────────────────────────────────────────────────
450
451    #[test]
452    fn printf_all_present_unchanged() {
453        // Sanity: a multi-arg format with no None inputs. This
454        // is the regression guard for the overwhelming common
455        // case the body actually handles.
456        let node = Printf::new("a={} b={}".to_string(), 2);
457        let mut out = [Value::None];
458        node.eval(&[Value::U64(1), Value::U64(2)], &mut out);
459        assert_eq!(out[0].as_str(), "a=1 b=2");
460    }
461
462    #[test]
463    fn printf_no_placeholders_still_renders() {
464        // Edge: a format with no placeholders. The result is
465        // the literal string.
466        let node = Printf::new("static text".to_string(), 0);
467        let mut out = [Value::None];
468        node.eval(&[], &mut out);
469        assert_eq!(out[0].as_str(), "static text");
470    }
471}