1use crate::engine::template::{Data, lookup};
35use serde_json::Value;
36
37const MAX_DEPTH: usize = 8;
39const MAX_OUTPUT: usize = 256 * 1024;
42
43#[derive(Debug, Clone, PartialEq)]
45enum Node {
46 Text(String),
47 Interp(String),
49 If {
51 expr: String,
52 then: Vec<Node>,
53 otherwise: Vec<Node>,
54 },
55 Each {
57 expr: String,
58 body: Vec<Node>,
59 },
60}
61
62#[derive(Debug, Clone, PartialEq)]
65pub struct Template {
66 nodes: Vec<Node>,
67 pub roots: Vec<String>,
70 pub needs_cel: bool,
72}
73
74impl Template {
75 pub fn parse(src: &str) -> Result<Template, String> {
77 let mut p = Parser {
78 s: src,
79 i: 0,
80 depth: 0,
81 };
82 let nodes = p.block(None)?;
83 if p.i < src.len() {
84 return Err(format!(
85 "unexpected {:?} — a closing tag with no opening block",
86 &src[p.i..(p.i + 20).min(src.len())]
87 ));
88 }
89 let mut t = Template {
90 nodes,
91 roots: Vec::new(),
92 needs_cel: false,
93 };
94 let mut roots = Vec::new();
95 let mut needs_cel = false;
96 collect(&t.nodes, &mut roots, &mut needs_cel);
97 roots.sort();
98 roots.dedup();
99 t.roots = roots;
100 t.needs_cel = needs_cel;
101 Ok(t)
102 }
103
104 pub fn reads(&self, name: &str) -> bool {
106 self.roots.iter().any(|r| r == name)
107 }
108
109 pub fn render(&self, data: &Data) -> Result<String, String> {
113 let mut out = String::new();
114 render_nodes(&self.nodes, data, &mut out, 0)?;
115 Ok(out)
116 }
117}
118
119struct Parser<'a> {
120 s: &'a str,
121 i: usize,
122 depth: usize,
123}
124
125impl<'a> Parser<'a> {
126 fn block(&mut self, stop: Option<&[&str]>) -> Result<Vec<Node>, String> {
128 if self.depth > MAX_DEPTH {
129 return Err(format!("template nests deeper than {MAX_DEPTH} blocks"));
130 }
131 let mut out = Vec::new();
132 loop {
133 let Some(start) = self.s[self.i..].find("{{") else {
134 if self.i < self.s.len() {
135 out.push(Node::Text(self.s[self.i..].to_string()));
136 self.i = self.s.len();
137 }
138 if stop.is_some() {
139 return Err("unclosed block: expected a closing tag".into());
140 }
141 return Ok(out);
142 };
143 let start = self.i + start;
144 if start > self.i {
145 out.push(Node::Text(self.s[self.i..start].to_string()));
146 }
147 let after = start + 2;
148 let Some(rel) = self.s[after..].find("}}") else {
149 return Err("unterminated `{{` — every tag needs a closing `}}`".into());
150 };
151 let raw = self.s[after..after + rel].trim().to_string();
152 let next = after + rel + 2;
153 if let Some(stops) = stop
155 && stops.iter().any(|s| *s == raw)
156 {
157 self.i = start;
158 return Ok(out);
159 }
160 self.i = next;
161 if raw.starts_with('!') {
162 continue; }
164 if let Some(expr) = raw.strip_prefix("#if ") {
165 let expr = expr.trim().to_string();
166 self.depth += 1;
167 let then = self.block(Some(&["else", "/if"]))?;
168 let otherwise = if self.peek_tag() == Some("else".into()) {
169 self.consume_tag();
170 self.block(Some(&["/if"]))?
171 } else {
172 Vec::new()
173 };
174 self.expect_tag("/if")?;
175 self.depth -= 1;
176 out.push(Node::If {
177 expr,
178 then,
179 otherwise,
180 });
181 continue;
182 }
183 if let Some(expr) = raw.strip_prefix("#each ") {
184 let expr = expr.trim().to_string();
185 self.depth += 1;
186 let body = self.block(Some(&["/each"]))?;
187 self.expect_tag("/each")?;
188 self.depth -= 1;
189 out.push(Node::Each { expr, body });
190 continue;
191 }
192 if raw.starts_with('/') || raw == "else" {
193 return Err(format!(
194 "{{{{{raw}}}}} is a closing tag with no opening block"
195 ));
196 }
197 if raw.starts_with('#') {
198 return Err(format!(
199 "unknown block tag {{{{{raw}}}}} — the template language has `#if` and `#each` only"
200 ));
201 }
202 if raw.is_empty() {
203 return Err("empty `{{}}` tag".into());
204 }
205 out.push(Node::Interp(raw));
206 }
207 }
208
209 fn peek_tag(&self) -> Option<String> {
210 let rest = &self.s[self.i..];
211 let after = rest.strip_prefix("{{")?;
212 let end = after.find("}}")?;
213 Some(after[..end].trim().to_string())
214 }
215
216 fn consume_tag(&mut self) {
217 if let Some(rest) = self.s[self.i..].strip_prefix("{{")
218 && let Some(end) = rest.find("}}")
219 {
220 self.i += 2 + end + 2;
221 }
222 }
223
224 fn expect_tag(&mut self, tag: &str) -> Result<(), String> {
225 match self.peek_tag() {
226 Some(t) if t == tag => {
227 self.consume_tag();
228 Ok(())
229 }
230 _ => Err(format!("expected {{{{{tag}}}}}")),
231 }
232 }
233}
234
235fn collect(nodes: &[Node], roots: &mut Vec<String>, needs_cel: &mut bool) {
237 for n in nodes {
238 match n {
239 Node::Text(_) => {}
240 Node::Interp(e) => note(e, roots, needs_cel),
241 Node::If {
242 expr,
243 then,
244 otherwise,
245 } => {
246 note(expr, roots, needs_cel);
247 collect(then, roots, needs_cel);
248 collect(otherwise, roots, needs_cel);
249 }
250 Node::Each { expr, body } => {
251 note(expr, roots, needs_cel);
252 collect(body, roots, needs_cel);
253 }
254 }
255 }
256}
257
258fn note(expr: &str, roots: &mut Vec<String>, needs_cel: &mut bool) {
259 match bare_path(expr) {
260 Some(p) => {
261 let root = p.split(['.', '[']).next().unwrap_or(p).to_string();
262 if root != "this" && !root.starts_with('@') {
264 roots.push(root);
265 }
266 }
267 None => *needs_cel = true,
268 }
269}
270
271fn bare_path(expr: &str) -> Option<&str> {
273 let e = expr.trim();
274 if e.is_empty() {
275 return None;
276 }
277 let ok = e
278 .chars()
279 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '@');
280 if ok && !e.starts_with('.') && !e.ends_with('.') {
281 Some(e)
282 } else {
283 None
284 }
285}
286
287fn render_nodes(nodes: &[Node], data: &Data, out: &mut String, depth: usize) -> Result<(), String> {
288 if depth > MAX_DEPTH {
289 return Err("template recursion exceeded".into());
290 }
291 for n in nodes {
292 if out.len() > MAX_OUTPUT {
293 return Err(format!(
294 "rendered prompt exceeds {MAX_OUTPUT} bytes — narrow an `{{{{#each}}}}`"
295 ));
296 }
297 match n {
298 Node::Text(t) => out.push_str(t),
299 Node::Interp(e) => {
300 let v = eval(e, data)?;
301 out.push_str(&stringify(&v));
302 }
303 Node::If {
304 expr,
305 then,
306 otherwise,
307 } => {
308 let v = eval(expr, data)?;
309 let branch = if truthy(&v) { then } else { otherwise };
310 render_nodes(branch, data, out, depth + 1)?;
311 }
312 Node::Each { expr, body } => {
313 let v = eval(expr, data)?;
314 let items: Vec<Value> = match v {
315 Value::Array(a) => a,
316 Value::Null => Vec::new(),
317 Value::Object(o) => o.into_values().collect(),
320 other => vec![other],
321 };
322 for (i, item) in items.iter().enumerate() {
323 let mut scoped = data.clone();
324 scoped.insert("this".into(), item.clone());
325 scoped.insert("@index".into(), Value::from(i as u64));
326 render_nodes(body, &scoped, out, depth + 1)?;
327 }
328 }
329 }
330 }
331 Ok(())
332}
333
334fn eval(expr: &str, data: &Data) -> Result<Value, String> {
336 if let Some(p) = bare_path(expr) {
337 return Ok(lookup(p, data).unwrap_or(Value::Null));
338 }
339 let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
340 crate::cel::eval_value(expr, &vars).map_err(|e| format!("{expr:?}: {e}"))
341}
342
343fn stringify(v: &Value) -> String {
344 match v {
345 Value::String(s) => s.clone(),
346 Value::Null => String::new(),
347 other => other.to_string(),
348 }
349}
350
351fn truthy(v: &Value) -> bool {
353 match v {
354 Value::Null => false,
355 Value::Bool(b) => *b,
356 Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
357 Value::String(s) => !s.is_empty(),
358 Value::Array(a) => !a.is_empty(),
359 Value::Object(o) => !o.is_empty(),
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use serde_json::json;
367
368 fn data(pairs: &[(&str, Value)]) -> Data {
369 pairs
370 .iter()
371 .map(|(k, v)| ((*k).to_string(), v.clone()))
372 .collect()
373 }
374
375 #[test]
376 fn interpolation_and_paths() {
377 let t = Template::parse("You are {{instance}} ({{agent.mode}}).").unwrap();
378 let d = data(&[
379 ("instance", json!("beacon")),
380 ("agent", json!({"mode": "daemon"})),
381 ]);
382 assert_eq!(t.render(&d).unwrap(), "You are beacon (daemon).");
383 assert!(!t.needs_cel, "bare paths need no CEL");
384 assert_eq!(t.roots, vec!["agent".to_string(), "instance".to_string()]);
385 }
386
387 #[test]
388 fn each_iterates_with_this_and_index() {
389 let t = Template::parse("{{#each xs}}{{@index}}:{{this.n}} {{/each}}").unwrap();
390 let d = data(&[("xs", json!([{"n": "a"}, {"n": "b"}]))]);
391 assert_eq!(t.render(&d).unwrap(), "0:a 1:b ");
392 }
393
394 #[test]
395 fn each_over_empty_and_absent_renders_nothing() {
396 let t = Template::parse("[{{#each xs}}x{{/each}}]").unwrap();
397 assert_eq!(t.render(&data(&[("xs", json!([]))])).unwrap(), "[]");
398 assert_eq!(t.render(&data(&[])).unwrap(), "[]");
399 }
400
401 #[test]
402 fn if_else_uses_emptiness_as_falsy() {
403 let t = Template::parse("{{#if xs}}some{{else}}none{{/if}}").unwrap();
404 assert_eq!(t.render(&data(&[("xs", json!([1]))])).unwrap(), "some");
405 assert_eq!(t.render(&data(&[("xs", json!([]))])).unwrap(), "none");
406 assert_eq!(t.render(&data(&[("xs", json!(""))])).unwrap(), "none");
407 assert_eq!(t.render(&data(&[])).unwrap(), "none");
408 }
409
410 #[test]
411 fn nested_blocks_compose() {
412 let t = Template::parse(
413 "{{#each svc}}- {{this.name}}{{#if this.tags}} [{{this.tags}}]{{/if}}\n{{/each}}",
414 )
415 .unwrap();
416 let d = data(&[(
417 "svc",
418 json!([{"name":"billing","tags":["sensitive"]},{"name":"docs"}]),
419 )]);
420 assert_eq!(
421 t.render(&d).unwrap(),
422 "- billing [[\"sensitive\"]]\n- docs\n"
423 );
424 }
425
426 #[test]
427 fn comments_are_dropped() {
428 let t = Template::parse("a{{! not rendered }}b").unwrap();
429 assert_eq!(t.render(&data(&[])).unwrap(), "ab");
430 }
431
432 #[test]
433 fn malformed_templates_are_refused_at_parse() {
434 for (src, want) in [
435 ("{{#each xs}}oops", "unclosed block"),
436 ("{{ unterminated", "unterminated"),
437 ("{{/each}}", "closing tag with no opening block"),
438 ("{{#while x}}{{/while}}", "unknown block tag"),
439 ("{{}}", "empty"),
440 ] {
441 let e = Template::parse(src).unwrap_err();
442 assert!(e.contains(want), "{src:?} → {e:?} (wanted {want:?})");
443 }
444 }
445
446 #[test]
447 fn expressions_are_flagged_as_needing_cel() {
448 let bare = Template::parse("{{#each services}}{{this.name}}{{/each}}").unwrap();
449 assert!(!bare.needs_cel);
450 let expr = Template::parse("{{#each take(services, 3)}}x{{/each}}").unwrap();
451 assert!(expr.needs_cel, "a call is not a bare path");
452 assert!(
453 Template::parse("{{#if size(peers) > 0}}y{{/if}}")
454 .unwrap()
455 .needs_cel
456 );
457 }
458
459 #[test]
460 fn the_instruction_guard_can_see_the_reference() {
461 assert!(
462 Template::parse("## I\n{{instruction}}")
463 .unwrap()
464 .reads("instruction")
465 );
466 assert!(
467 !Template::parse("nothing here")
468 .unwrap()
469 .reads("instruction")
470 );
471 }
472
473 #[test]
474 fn a_runaway_each_is_capped_not_unbounded() {
475 let t = Template::parse("{{#each xs}}{{this}}{{/each}}").unwrap();
476 let big: Vec<Value> = (0..60_000).map(|_| json!("0123456789")).collect();
477 let e = t.render(&data(&[("xs", Value::Array(big))])).unwrap_err();
478 assert!(e.contains("exceeds"), "{e}");
479 }
480
481 #[test]
482 fn nesting_beyond_the_cap_is_refused() {
483 let src = "{{#if a}}".repeat(MAX_DEPTH + 2) + &"{{/if}}".repeat(MAX_DEPTH + 2);
484 assert!(Template::parse(&src).unwrap_err().contains("nests deeper"));
485 }
486}