Skip to main content

nmbrs_workload/
template.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! TEMPLATE macro expansion.
5//!
6//! `TEMPLATE(name, default)` is resolved BEFORE YAML parsing. It
7//! performs simple textual substitution in the raw source string.
8//!
9//! Forms:
10//! - `TEMPLATE(name, default)` — use default if name not provided
11//! - `TEMPLATE(name)` — required, produces "UNSET:name" if missing
12//! - `TEMPLATE(name,)` — null default (empty string)
13//!
14//! The same variable referenced multiple times gets consistent
15//! substitution: the first occurrence with a default sets the value
16//! for all subsequent references.
17
18use std::collections::HashMap;
19
20/// Expand all TEMPLATE(...) macros in a source string.
21///
22/// `params` provides externally supplied values (e.g., from CLI).
23/// Returns the expanded string.
24pub fn expand_templates(source: &str, params: &HashMap<String, String>) -> String {
25    let mut resolved: HashMap<String, String> = params.clone();
26    let mut result = String::with_capacity(source.len());
27    let chars: Vec<char> = source.chars().collect();
28    let mut i = 0;
29
30    while i < chars.len() {
31        // Look for TEMPLATE( — compare chars, not bytes
32        let template_match = i + 9 <= chars.len()
33            && chars[i] == 'T'
34            && chars[i + 1] == 'E'
35            && chars[i + 2] == 'M'
36            && chars[i + 3] == 'P'
37            && chars[i + 4] == 'L'
38            && chars[i + 5] == 'A'
39            && chars[i + 6] == 'T'
40            && chars[i + 7] == 'E'
41            && chars[i + 8] == '(';
42
43        if template_match {
44            i += 9; // skip "TEMPLATE("
45
46            // Find matching closing paren, respecting nested parens and quotes
47            let mut depth = 1;
48            let arg_start = i;
49            let mut in_quote = false;
50            while i < chars.len() && depth > 0 {
51                match chars[i] {
52                    '\'' if !in_quote => in_quote = true,
53                    '\'' if in_quote => in_quote = false,
54                    '(' if !in_quote => depth += 1,
55                    ')' if !in_quote => depth -= 1,
56                    _ => {}
57                }
58                if depth > 0 {
59                    i += 1;
60                }
61            }
62
63            if depth != 0 {
64                // Unclosed TEMPLATE — pass through remaining chars
65                for c in &chars[arg_start - 9..] {
66                    result.push(*c);
67                }
68                break;
69            }
70
71            let args_str: String = chars[arg_start..i].iter().collect();
72            i += 1; // skip closing ')'
73
74            // Parse args: split on first comma (default may contain commas)
75            let (name, default) = if let Some(comma_pos) = args_str.find(',') {
76                let name = args_str[..comma_pos].trim().to_string();
77                let default = args_str[comma_pos + 1..].trim().to_string();
78                (name, Some(default))
79            } else {
80                (args_str.trim().to_string(), None)
81            };
82
83            // Resolve value
84            let value = if let Some(v) = resolved.get(&name) {
85                v.clone()
86            } else if let Some(ref d) = default {
87                if d.is_empty() {
88                    // TEMPLATE(name,) → empty string (null default)
89                    String::new()
90                } else {
91                    // Store default for consistent resolution of same var
92                    resolved.insert(name.clone(), d.clone());
93                    d.clone()
94                }
95            } else {
96                // No default, not provided → UNSET marker
97                format!("UNSET:{name}")
98            };
99
100            result.push_str(&value);
101        } else {
102            result.push(chars[i]);
103            i += 1;
104        }
105    }
106
107    result
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn expand(source: &str) -> String {
115        expand_templates(source, &HashMap::new())
116    }
117
118    fn expand_with(source: &str, params: &[(&str, &str)]) -> String {
119        let map: HashMap<String, String> = params
120            .iter()
121            .map(|(k, v)| (k.to_string(), v.to_string()))
122            .collect();
123        expand_templates(source, &map)
124    }
125
126    #[test]
127    fn template_with_default() {
128        assert_eq!(
129            expand("name: TEMPLATE(myname, thedefault)"),
130            "name: thedefault"
131        );
132    }
133
134    #[test]
135    fn template_no_default_unset() {
136        assert_eq!(expand("name: TEMPLATE(myname)"), "name: UNSET:myname");
137    }
138
139    #[test]
140    fn template_null_default() {
141        assert_eq!(expand("name: TEMPLATE(myname,)"), "name: ");
142    }
143
144    #[test]
145    fn template_provided_param() {
146        assert_eq!(
147            expand_with("count: TEMPLATE(n, 100)", &[("n", "500")]),
148            "count: 500"
149        );
150    }
151
152    #[test]
153    fn template_consistent_resolution() {
154        // First occurrence sets default, second uses it
155        let result = expand("a: TEMPLATE(x, hello)\nb: TEMPLATE(x)");
156        assert_eq!(result, "a: hello\nb: hello");
157    }
158
159    #[test]
160    fn template_param_overrides_default() {
161        assert_eq!(
162            expand_with("v: TEMPLATE(x, default)", &[("x", "override")]),
163            "v: override"
164        );
165    }
166
167    #[test]
168    fn template_no_templates_passthrough() {
169        assert_eq!(expand("just plain text"), "just plain text");
170    }
171
172    #[test]
173    fn template_multiple_on_one_line() {
174        assert_eq!(expand("TEMPLATE(a, 1) and TEMPLATE(b, 2)"), "1 and 2");
175    }
176
177    #[test]
178    fn template_in_yaml_context() {
179        let yaml = r#"
180bindings:
181  key: Mod(TEMPLATE(keycount, 1000000))
182  val: Hash(); Mod(TEMPLATE(valcount, 500000))
183"#;
184        let result = expand(yaml);
185        assert!(result.contains("Mod(1000000)"));
186        assert!(result.contains("Mod(500000)"));
187    }
188
189    #[test]
190    fn template_nested_parens() {
191        // TEMPLATE arg contains parens (in a function call)
192        assert_eq!(expand("TEMPLATE(expr, ToString())"), "ToString()");
193    }
194}