Skip to main content

rskit_util/template/
dynamic.rs

1//! Open-set, `{{var}}` brace templates with runtime-supplied variables.
2//!
3//! Unlike [`Template`](super::Template), which validates against a fixed,
4//! typed placeholder set known at compile time, a [`DynamicTemplate`] carries
5//! an open set of string-named variables resolved against a caller-supplied
6//! lookup at render time. Names are matched leniently: `{{ name }}` and
7//! `{{name}}` are equivalent, and any sequence that is not a well-formed
8//! placeholder is preserved verbatim. Use this for prompt-style templates
9//! whose variable set is data-driven rather than enumerated in code.
10
11use std::collections::BTreeSet;
12
13use super::error::TemplateError;
14
15/// One parsed part of a dynamic template.
16#[derive(Debug, Clone, Eq, PartialEq)]
17enum Part {
18    /// Literal text.
19    Literal(String),
20    /// A variable name (without braces).
21    Variable(String),
22}
23
24/// A parsed `{{var}}` template with an open set of named variables.
25#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct DynamicTemplate {
27    parts: Vec<Part>,
28}
29
30impl DynamicTemplate {
31    /// Parse a template string. Parsing is lenient and never fails: malformed
32    /// `{{` / `}}` runs and invalid names are kept as literal text.
33    #[must_use]
34    pub fn parse(template: &str) -> Self {
35        let mut parts = Vec::new();
36        let mut rest = template;
37        while let Some(start) = rest.find("{{") {
38            let (before, after) = (&rest[..start], &rest[start + 2..]);
39            if !before.is_empty() {
40                parts.push(Part::Literal(before.to_owned()));
41            }
42            let Some(end) = after.find("}}") else {
43                parts.push(Part::Literal("{{".to_owned()));
44                rest = after;
45                continue;
46            };
47            let name = after[..end].trim();
48            if is_valid_name(name) {
49                parts.push(Part::Variable(name.to_owned()));
50            } else {
51                parts.push(Part::Literal(format!("{{{{{}}}}}", &after[..end])));
52            }
53            rest = &after[end + 2..];
54        }
55        if !rest.is_empty() {
56            parts.push(Part::Literal(rest.to_owned()));
57        }
58        Self { parts }
59    }
60
61    /// Return the sorted set of variable names referenced by the template.
62    #[must_use]
63    pub fn variables(&self) -> BTreeSet<String> {
64        self.parts
65            .iter()
66            .filter_map(|part| match part {
67                Part::Variable(name) => Some(name.clone()),
68                Part::Literal(_) => None,
69            })
70            .collect()
71    }
72
73    /// Render the template, resolving each variable via `lookup`. A variable
74    /// with no value yields [`TemplateError::MissingVariable`].
75    pub fn render<F>(&self, mut lookup: F) -> Result<String, TemplateError>
76    where
77        F: FnMut(&str) -> Option<String>,
78    {
79        let mut out = String::new();
80        for part in &self.parts {
81            match part {
82                Part::Literal(text) => out.push_str(text),
83                Part::Variable(name) => {
84                    let value =
85                        lookup(name).ok_or_else(|| TemplateError::MissingVariable(name.clone()))?;
86                    out.push_str(&value);
87                }
88            }
89        }
90        Ok(out)
91    }
92}
93
94fn is_valid_name(name: &str) -> bool {
95    !name.is_empty()
96        && name
97            .chars()
98            .all(|c| c == '_' || c == '-' || c.is_ascii_alphanumeric())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::{DynamicTemplate, TemplateError};
104
105    #[test]
106    fn renders_dynamic_variables_with_optional_whitespace() {
107        let tpl = DynamicTemplate::parse("hi {{ name }}, {{count}} items");
108        let out = tpl
109            .render(|n| match n {
110                "name" => Some("ada".to_owned()),
111                "count" => Some("3".to_owned()),
112                _ => None,
113            })
114            .expect("renders");
115        assert_eq!(out, "hi ada, 3 items");
116    }
117
118    #[test]
119    fn lists_referenced_variables() {
120        let tpl = DynamicTemplate::parse("{{a}}{{ b }}{{a}}");
121        let vars = tpl.variables();
122        assert!(vars.contains("a") && vars.contains("b") && vars.len() == 2);
123    }
124
125    #[test]
126    fn missing_variable_errors() {
127        let tpl = DynamicTemplate::parse("{{x}}");
128        let err = tpl.render(|_| None).expect_err("missing");
129        assert_eq!(err, TemplateError::MissingVariable("x".to_owned()));
130    }
131
132    #[test]
133    fn malformed_placeholders_are_literal() {
134        let tpl = DynamicTemplate::parse("a {{ bad name }} b {{");
135        let out = tpl.render(|_| None).expect("no valid vars");
136        assert_eq!(out, "a {{ bad name }} b {{");
137    }
138}