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