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    /// The parsed form of a format string, interned for the process
150    /// so the same text parses once. Immutable once made, like a
151    /// static string.
152    pub fn interned(fmt: &str) -> &'static ParsedFormat {
153        use std::sync::RwLock;
154        static FORMATS: RwLock<Option<std::collections::HashMap<String, &'static ParsedFormat>>> =
155            RwLock::new(None);
156        if let Some(p) = FORMATS
157            .read()
158            .unwrap()
159            .as_ref()
160            .and_then(|m| m.get(fmt).copied())
161        {
162            return p;
163        }
164        let mut guard = FORMATS.write().unwrap();
165        let map = guard.get_or_insert_with(std::collections::HashMap::new);
166        if let Some(p) = map.get(fmt).copied() {
167            return p;
168        }
169        let leaked: &'static ParsedFormat = Box::leak(Box::new(Self::from_format_str(fmt)));
170        map.insert(fmt.to_string(), leaked);
171        leaked
172    }
173
174    /// Render the format over `argc` arguments fetched by index.
175    /// Panics, as the node always has, when a placeholder names an
176    /// argument that was not supplied.
177    pub fn render_with<'a>(&self, argc: usize, arg: impl Fn(usize) -> FmtArg<'a>) -> String {
178        let mut result = String::new();
179        self.render_into(argc, arg, &mut result);
180        result
181    }
182
183    /// Render into any text sink.
184    pub fn render_into<'a, W: std::fmt::Write>(
185        &self,
186        argc: usize,
187        arg: impl Fn(usize) -> FmtArg<'a>,
188        out: &mut W,
189    ) {
190        for seg in &self.segments {
191            match seg {
192                Segment::Literal(s) => {
193                    let _ = out.write_str(s);
194                }
195                Segment::Placeholder(spec) => {
196                    if spec.index >= argc {
197                        panic!(
198                            "printf: format references input #{} but only {argc} wire input(s) supplied",
199                            spec.index,
200                        );
201                    }
202                    let _ = out.write_str(&format_arg(&arg(spec.index), spec));
203                }
204            }
205        }
206    }
207}
208
209fn format_arg(arg: &FmtArg<'_>, spec: &FormatSpec) -> String {
210    match arg {
211        FmtArg::U64(v) => format_u64(*v, spec),
212        FmtArg::F64(v) => format_f64(*v, spec),
213        FmtArg::Bool(v) => v.to_string(),
214        FmtArg::Str(v) => {
215            if let Some(w) = spec.width {
216                format!("{:>width$}", v, width = w)
217            } else {
218                v.to_string()
219            }
220        }
221        // Extension values render through their reflected display form,
222        // the same text `to_display_string` produces, so a Streamer or
223        // Partition interpolates as the author would expect.
224        FmtArg::Value(val @ Value::Ext(_)) => val.to_display_string(),
225        FmtArg::Value(val) => format!("{val:?}"),
226    }
227}
228
229fn format_u64(v: u64, spec: &FormatSpec) -> String {
230    let raw = match spec.conversion {
231        'x' => format!("{v:x}"),
232        'X' => format!("{v:X}"),
233        'b' => format!("{v:b}"),
234        'o' => format!("{v:o}"),
235        _ => v.to_string(),
236    };
237    apply_width(&raw, spec)
238}
239
240fn format_f64(v: f64, spec: &FormatSpec) -> String {
241    let raw = if let Some(prec) = spec.precision {
242        format!("{v:.prec$}")
243    } else {
244        // Bare `{}` for f64 uses Debug formatting so whole-number
245        // floats render as `1.0` instead of `1`, matching
246        // `Value::F64::to_display_string`. Authors who want
247        // integer-style output for whole floats specify a
248        // precision (`{:.0}`) or convert via `format_u64`.
249        format!("{v:?}")
250    };
251    apply_width(&raw, spec)
252}
253
254fn apply_width(s: &str, spec: &FormatSpec) -> String {
255    if let Some(w) = spec.width {
256        if s.len() < w {
257            let pad = w - s.len();
258            let fill = spec.fill;
259            format!("{}{s}", std::iter::repeat_n(fill, pad).collect::<String>())
260        } else {
261            s.to_string()
262        }
263    } else {
264        s.to_string()
265    }
266}
267
268fn parse_format(fmt: &str) -> Vec<Segment> {
269    let mut segments = Vec::new();
270    let mut literal = String::new();
271    let chars: Vec<char> = fmt.chars().collect();
272    let mut i = 0;
273    let mut placeholder_idx = 0;
274
275    while i < chars.len() {
276        if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '{' {
277            literal.push('{');
278            i += 2;
279        } else if chars[i] == '{' {
280            if !literal.is_empty() {
281                segments.push(Segment::Literal(std::mem::take(&mut literal)));
282            }
283            // Find closing }
284            let start = i + 1;
285            while i < chars.len() && chars[i] != '}' {
286                i += 1;
287            }
288            let spec_str: String = chars[start..i].iter().collect();
289            let spec = parse_spec(&spec_str, placeholder_idx);
290            segments.push(Segment::Placeholder(spec));
291            placeholder_idx += 1;
292            i += 1; // skip }
293        } else if chars[i] == '}' && i + 1 < chars.len() && chars[i + 1] == '}' {
294            literal.push('}');
295            i += 2;
296        } else {
297            literal.push(chars[i]);
298            i += 1;
299        }
300    }
301
302    if !literal.is_empty() {
303        segments.push(Segment::Literal(literal));
304    }
305
306    segments
307}
308
309fn parse_spec(spec: &str, index: usize) -> FormatSpec {
310    let mut result = FormatSpec {
311        index,
312        width: None,
313        precision: None,
314        fill: ' ',
315        conversion: 'd',
316    };
317
318    if spec.is_empty() {
319        return result;
320    }
321
322    // Strip leading ':'
323    let spec = spec.strip_prefix(':').unwrap_or(spec);
324    if spec.is_empty() {
325        return result;
326    }
327
328    let chars: Vec<char> = spec.chars().collect();
329    let mut pos = 0;
330
331    // Check for zero-fill
332    if pos < chars.len()
333        && chars[pos] == '0'
334        && pos + 1 < chars.len()
335        && chars[pos + 1].is_ascii_digit()
336    {
337        result.fill = '0';
338        pos += 1;
339    }
340
341    // Width
342    let width_start = pos;
343    while pos < chars.len() && chars[pos].is_ascii_digit() {
344        pos += 1;
345    }
346    if pos > width_start {
347        let w: String = chars[width_start..pos].iter().collect();
348        result.width = Some(w.parse().unwrap());
349    }
350
351    // Precision
352    if pos < chars.len() && chars[pos] == '.' {
353        pos += 1;
354        let prec_start = pos;
355        while pos < chars.len() && chars[pos].is_ascii_digit() {
356            pos += 1;
357        }
358        if pos > prec_start {
359            let p: String = chars[prec_start..pos].iter().collect();
360            result.precision = Some(p.parse().unwrap());
361        }
362    }
363
364    // Conversion
365    if pos < chars.len() {
366        result.conversion = chars[pos];
367    }
368
369    result
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::ast::PolydatNode;
376
377    #[test]
378    fn printf_simple() {
379        let node = Printf::new("hello {}".to_string(), 1);
380        let mut out = [Value::None];
381        node.eval(&[Value::U64(42)], &mut out);
382        assert_eq!(out[0].as_str(), "hello 42");
383    }
384
385    #[test]
386    fn printf_multiple() {
387        let node = Printf::new("{} + {} = {}".to_string(), 3);
388        let mut out = [Value::None];
389        node.eval(&[Value::U64(1), Value::U64(2), Value::U64(3)], &mut out);
390        assert_eq!(out[0].as_str(), "1 + 2 = 3");
391    }
392
393    #[test]
394    fn printf_zero_pad() {
395        let node = Printf::new("{:05}".to_string(), 1);
396        let mut out = [Value::None];
397        node.eval(&[Value::U64(42)], &mut out);
398        assert_eq!(out[0].as_str(), "00042");
399    }
400
401    #[test]
402    fn printf_hex() {
403        let node = Printf::new("{:x}".to_string(), 1);
404        let mut out = [Value::None];
405        node.eval(&[Value::U64(255)], &mut out);
406        assert_eq!(out[0].as_str(), "ff");
407    }
408
409    #[test]
410    fn printf_hex_upper() {
411        let node = Printf::new("{:X}".to_string(), 1);
412        let mut out = [Value::None];
413        node.eval(&[Value::U64(255)], &mut out);
414        assert_eq!(out[0].as_str(), "FF");
415    }
416
417    #[test]
418    fn printf_precision() {
419        let node = Printf::new("{:.2}".to_string(), 1);
420        let mut out = [Value::None];
421        node.eval(&[Value::F64(3.14159)], &mut out);
422        assert_eq!(out[0].as_str(), "3.14");
423    }
424
425    #[test]
426    fn printf_mixed() {
427        let node = Printf::new("id={:05} val={:.1}".to_string(), 2);
428        let mut out = [Value::None];
429        node.eval(&[Value::U64(7), Value::F64(98.6)], &mut out);
430        assert_eq!(out[0].as_str(), "id=00007 val=98.6");
431    }
432
433    #[test]
434    fn printf_literal_braces() {
435        let node = Printf::new("{{escaped}} {}".to_string(), 1);
436        let mut out = [Value::None];
437        node.eval(&[Value::U64(1)], &mut out);
438        assert_eq!(out[0].as_str(), "{escaped} 1");
439    }
440
441    #[test]
442    fn printf_no_placeholders() {
443        let node = Printf::new("just text".to_string(), 0);
444        let mut out = [Value::None];
445        node.eval(&[], &mut out);
446        assert_eq!(out[0].as_str(), "just text");
447    }
448
449    #[test]
450    fn printf_string_input() {
451        let node = Printf::new("hello {}".to_string(), 1);
452        let mut out = [Value::None];
453        node.eval(&[Value::Str("world".into())], &mut out);
454        assert_eq!(out[0].as_str(), "hello world");
455    }
456
457    // ────────────────────────────────────────────────────────
458    // None propagation (SRD-73 follow-up)
459    //
460    // String interpolation evaluates to Value::None when any
461    // referenced input is Value::None. The canonical
462    // None-propagation surface is the Polydat kernel's SRD-74
463    // Rule 1 guard (engines.rs): any
464    // node whose inputs include Value::None and which doesn't
465    // override `accepts_none_inputs` emits None on every output
466    // BEFORE the body is invoked. The body therefore never
467    // observes a None-tainted `parts` slice at production time.
468    //
469    // End-to-end coverage of the kernel-level None-propagation
470    // through printf lives in `tests/scope_composition.rs`
471    // (`const_with_unbound_interpolation_falls_through_to_outer`).
472    // There are no direct-eval unit tests for a body-side check:
473    // the body never sees a None input.
474    // ────────────────────────────────────────────────────────
475
476    #[test]
477    fn printf_all_present_unchanged() {
478        // Sanity: a multi-arg format with no None inputs. This
479        // is the regression guard for the overwhelming common
480        // case the body actually handles.
481        let node = Printf::new("a={} b={}".to_string(), 2);
482        let mut out = [Value::None];
483        node.eval(&[Value::U64(1), Value::U64(2)], &mut out);
484        assert_eq!(out[0].as_str(), "a=1 b=2");
485    }
486
487    #[test]
488    fn printf_no_placeholders_still_renders() {
489        // Edge: a format with no placeholders. The result is
490        // the literal string.
491        let node = Printf::new("static text".to_string(), 0);
492        let mut out = [Value::None];
493        node.eval(&[], &mut out);
494        assert_eq!(out[0].as_str(), "static text");
495    }
496}