Skip to main content

nmbrs_runtime/scope_synth/
helpers.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Surface #7 helpers — pure utilities for translating between
5//! typed runtime values and Polydat source-text form.
6//!
7//! These functions encode nmbrs-runtime's Polydat source conventions:
8//! - how a [`polydat::ast::Value`] becomes a fold-eligible GK
9//!   literal,
10//! - how a workload-param string becomes a quoted-or-numeric GK
11//!   literal,
12//! - how a [`polydat::ast::PortType`] becomes an `extern`
13//!   declaration type name,
14//! - how `{name}` placeholders are scanned out of clause / body
15//!   text.
16//!
17//! They're pure functions with no comprehension- or scope-
18//! specific knowledge; the scope builders import them as walking
19//! primitives. The polydat side retains parallel copies during
20//! the cutover (still used by `polydat::iteration::comprehension::synthesize_for_each_scope`);
21//! those copies retire when the legacy synthesis module is
22//! deleted at the end of PR 9c-1b.
23
24use std::collections::HashSet;
25
26use polydat::ast::{PortType, Value};
27
28/// Pick the Polydat port type for a workload-param string value.
29///
30/// Numeric values widen to `u64` / `f64`; `true`/`false` →
31/// `bool`; everything else → `String`.
32pub fn workload_param_type_name(value: &str) -> &'static str {
33    let trimmed = value.trim();
34    if trimmed.parse::<u64>().is_ok() {
35        "u64"
36    } else if trimmed.parse::<f64>().is_ok() {
37        "f64"
38    } else if trimmed == "true" || trimmed == "false" {
39        "bool"
40    } else {
41        "String"
42    }
43}
44
45/// Format a typed [`Value`] as a Polydat source literal — strict
46/// variant. Returns `None` when the value isn't representable as
47/// a literal (`Bytes`, `Json`, `Ext`, `Handle`, vectors). Used
48/// by the for_each scope synthesizer when inlining const-folded
49/// parent outputs; falls back to extern cascade in `None` cases.
50pub fn format_value_as_final_literal(v: &Value) -> Option<String> {
51    match v {
52        Value::U64(n) => Some(n.to_string()),
53        Value::F64(f) => {
54            if f.fract() == 0.0 && f.is_finite() {
55                Some(format!("{f:.1}"))
56            } else {
57                Some(format!("{f}"))
58            }
59        }
60        Value::Bool(b) => Some(b.to_string()),
61        Value::Str(s) => {
62            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
63            Some(format!("\"{escaped}\""))
64        }
65        _ => None,
66    }
67}
68
69/// Format a typed [`Value`] as a Polydat source literal.
70///
71/// Used when emitting `final <name> := <literal>` lines for
72/// per-iteration scope synthesis. Falls back to a quoted-display
73/// form for non-scalar variants (acceptable for iter-vars, which
74/// are scalar in practice).
75pub fn format_value_as_polydat_literal(v: &Value) -> String {
76    match v {
77        Value::U64(n) => n.to_string(),
78        Value::F64(f) => {
79            // Always include decimal point so the parser sees an
80            // f64 literal, not an integer.
81            if f.fract() == 0.0 && f.is_finite() {
82                format!("{f:.1}")
83            } else {
84                format!("{f}")
85            }
86        }
87        Value::Bool(b) => b.to_string(),
88        Value::Str(s) => {
89            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
90            format!("\"{escaped}\"")
91        }
92        _ => {
93            let display = v.to_display_string();
94            let escaped = display.replace('\\', "\\\\").replace('"', "\\\"");
95            format!("\"{escaped}\"")
96        }
97    }
98}
99
100/// Format a workload-param string value as a Polydat literal.
101///
102/// Numeric inputs pass through untouched; non-numeric inputs get
103/// wrapped in double quotes (with `\` / `"` escaping). The lexer
104/// has no boolean token kind, so a bare `false` would parse as
105/// an identifier (wire reference) and break kernel compilation;
106/// this routes through the string path.
107pub fn format_workload_param_as_polydat_literal(value: &str) -> String {
108    let trimmed = value.trim();
109    if trimmed.parse::<u64>().is_ok() || trimmed.parse::<f64>().is_ok() {
110        trimmed.to_string()
111    } else {
112        let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
113        format!("\"{escaped}\"")
114    }
115}
116
117/// Convert a scalar [`Value`] to its workload-param string form.
118///
119/// Returns `None` for non-scalar variants (vectors, JSON,
120/// handles, bytes, ext) — those aren't representable as GK
121/// source literals.
122pub fn value_to_param_string(v: &Value) -> Option<String> {
123    match v {
124        Value::U64(n) => Some(n.to_string()),
125        Value::F64(n) => Some(n.to_string()),
126        Value::Bool(b) => Some(b.to_string()),
127        Value::Str(s) => Some(s.to_string()),
128        _ => None,
129    }
130}
131
132/// Map a Polydat [`PortType`] to the extern declaration's type
133/// keyword. Thin wrapper over [`PortType::to_keyword`] — the
134/// canonical str↔PortType table on the enum itself — so every
135/// synthesized `extern <name>: <keyword>` round-trips byte-cleanly
136/// back through the DSL parser.
137pub fn port_type_to_extern_name(t: PortType) -> &'static str {
138    t.to_keyword()
139}
140
141/// Collect every leaf `{name}` placeholder from a list of clause
142/// spec texts.
143///
144/// "Leaf" means a `{...}` whose body contains no further `{` —
145/// the dynamic case (`{a_{b}_c}`) is handled at runtime by the
146/// iterative interpolator. Honors `\{` / `\}` escapes (same
147/// escape syntax `interpolate` uses).
148pub fn collect_leaf_placeholders(texts: &[String]) -> HashSet<String> {
149    let mut out = HashSet::new();
150    for text in texts {
151        scan_one(text, &mut out);
152    }
153    out
154}
155
156/// Scan one text for leaf `{name}` placeholders, inserting each
157/// into `out`. Companion to [`collect_leaf_placeholders`] for
158/// callers that already have a [`HashSet`] to grow incrementally.
159pub fn scan_one(text: &str, out: &mut HashSet<String>) {
160    let bytes = text.as_bytes();
161    let n = bytes.len();
162    let mut i = 0;
163    while i < n {
164        if bytes[i] == b'\\' && i + 1 < n && (bytes[i + 1] == b'{' || bytes[i + 1] == b'}') {
165            i += 2;
166            continue;
167        }
168        if bytes[i] == b'{' {
169            let mut j = i + 1;
170            let mut nested = false;
171            while j < n {
172                if bytes[j] == b'\\' && j + 1 < n && (bytes[j + 1] == b'{' || bytes[j + 1] == b'}')
173                {
174                    j += 2;
175                    continue;
176                }
177                if bytes[j] == b'{' {
178                    nested = true;
179                    break;
180                }
181                if bytes[j] == b'}' {
182                    break;
183                }
184                j += 1;
185            }
186            if !nested && j < n && bytes[j] == b'}' {
187                let name = &text[i + 1..j];
188                if !name.is_empty() {
189                    out.insert(name.to_string());
190                }
191                i = j + 1;
192                continue;
193            }
194            i += 1;
195            continue;
196        }
197        i += 1;
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn workload_param_type_name_classifies_basic_scalars() {
207        assert_eq!(workload_param_type_name("42"), "u64");
208        assert_eq!(workload_param_type_name("3.14"), "f64");
209        assert_eq!(workload_param_type_name("true"), "bool");
210        assert_eq!(workload_param_type_name("false"), "bool");
211        assert_eq!(workload_param_type_name("hello"), "String");
212    }
213
214    #[test]
215    fn format_value_as_polydat_literal_renders_scalars() {
216        assert_eq!(format_value_as_polydat_literal(&Value::U64(42)), "42");
217        assert_eq!(format_value_as_polydat_literal(&Value::Bool(true)), "true");
218        assert_eq!(
219            format_value_as_polydat_literal(&Value::Str("x".into())),
220            "\"x\""
221        );
222        // f64 with integral value still gets decimal point so the
223        // parser doesn't see it as u64.
224        assert_eq!(format_value_as_polydat_literal(&Value::F64(2.0)), "2.0");
225    }
226
227    #[test]
228    fn format_workload_param_quotes_non_numeric() {
229        assert_eq!(format_workload_param_as_polydat_literal("42"), "42");
230        assert_eq!(
231            format_workload_param_as_polydat_literal("hello"),
232            "\"hello\""
233        );
234        // Bool string routes through quoted path (lexer has no
235        // bool keyword).
236        assert_eq!(format_workload_param_as_polydat_literal("true"), "\"true\"");
237    }
238
239    #[test]
240    fn value_to_param_string_handles_scalars_only() {
241        assert_eq!(value_to_param_string(&Value::U64(7)), Some("7".to_string()));
242        assert_eq!(
243            value_to_param_string(&Value::Str("y".into())),
244            Some("y".to_string())
245        );
246        // Non-scalar variants return None — caller falls back.
247        assert_eq!(value_to_param_string(&Value::None), None);
248    }
249
250    #[test]
251    fn collect_leaf_placeholders_extracts_simple_names() {
252        let names = collect_leaf_placeholders(&[
253            "k in 1..{n}".to_string(),
254            "{profile} matches {prefix}".to_string(),
255        ]);
256        assert!(names.contains("n"));
257        assert!(names.contains("profile"));
258        assert!(names.contains("prefix"));
259    }
260
261    #[test]
262    fn collect_leaf_placeholders_skips_nested() {
263        // Dynamic `{a_{b}_c}` form — outer placeholder skipped
264        // (resolved at runtime), inner is extracted.
265        let names = collect_leaf_placeholders(&["{a_{b}_c}".to_string()]);
266        assert!(names.contains("b"));
267        assert!(!names.contains("a_{b}_c"));
268    }
269
270    #[test]
271    fn collect_leaf_placeholders_honors_escapes() {
272        let names = collect_leaf_placeholders(&["\\{not_a_placeholder\\}".to_string()]);
273        assert!(names.is_empty());
274    }
275}