1use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26use std::rc::Rc;
27
28use crate::value::{values_equal, VmError, VmValue};
29
30pub fn validate_template_syntax(src: &str) -> Result<(), String> {
35 parse(src).map(|_| ()).map_err(|e| e.message())
36}
37
38pub(crate) fn render_template_result(
42 template: &str,
43 bindings: Option<&BTreeMap<String, VmValue>>,
44 base: Option<&Path>,
45 source_path: Option<&Path>,
46) -> Result<String, TemplateError> {
47 let nodes = parse(template).map_err(|mut e| {
48 if let Some(p) = source_path {
49 e.path = Some(p.to_path_buf());
50 }
51 e
52 })?;
53 let mut out = String::with_capacity(template.len());
54 let mut scope = Scope::new(bindings);
55 let mut rc = RenderCtx {
56 base: base.map(Path::to_path_buf),
57 include_stack: Vec::new(),
58 current_path: source_path.map(Path::to_path_buf),
59 };
60 render_nodes(&nodes, &mut scope, &mut rc, &mut out).map_err(|mut e| {
61 if e.path.is_none() {
62 e.path = source_path.map(Path::to_path_buf);
63 }
64 e
65 })?;
66 Ok(out)
67}
68
69#[derive(Debug, Clone)]
74pub(crate) struct TemplateError {
75 pub path: Option<PathBuf>,
76 pub line: usize,
77 pub col: usize,
78 pub kind: String,
79}
80
81impl TemplateError {
82 fn new(line: usize, col: usize, msg: impl Into<String>) -> Self {
83 Self {
84 path: None,
85 line,
86 col,
87 kind: msg.into(),
88 }
89 }
90
91 pub(crate) fn message(&self) -> String {
92 let p = self
93 .path
94 .as_ref()
95 .map(|p| format!("{} ", p.display()))
96 .unwrap_or_default();
97 format!("{}at {}:{}: {}", p, self.line, self.col, self.kind)
98 }
99}
100
101impl From<TemplateError> for VmError {
102 fn from(e: TemplateError) -> Self {
103 VmError::Thrown(VmValue::String(Rc::from(e.message())))
104 }
105}
106
107#[derive(Debug, Clone)]
112enum Token {
113 Text {
115 content: String,
116 trim_right: bool,
118 trim_left: bool,
120 },
121 Directive {
123 body: String,
124 line: usize,
125 col: usize,
126 },
127 Raw(String),
129}
130
131fn tokenize(src: &str) -> Result<Vec<Token>, TemplateError> {
132 let bytes = src.as_bytes();
133 let mut tokens: Vec<Token> = Vec::new();
134 let mut cursor = 0;
135 let mut pending_trim_left = false;
136 let len = bytes.len();
137
138 while cursor < len {
139 let open = find_from(src, cursor, "{{");
141 let text_end = open.unwrap_or(len);
142 let raw_text = &src[cursor..text_end];
143
144 let this_trim_left = pending_trim_left;
145 pending_trim_left = false;
146
147 let mut this_trim_right = false;
148 if let Some(o) = open {
149 if o + 2 < len && bytes[o + 2] == b'-' {
151 this_trim_right = true;
152 }
153 }
154
155 if !raw_text.is_empty() || this_trim_left || this_trim_right {
156 tokens.push(Token::Text {
157 content: raw_text.to_string(),
158 trim_right: this_trim_right,
159 trim_left: this_trim_left,
160 });
161 }
162
163 let Some(open) = open else {
164 break;
165 };
166
167 let body_start = open + 2 + if this_trim_right { 1 } else { 0 };
169
170 if body_start < len && bytes[body_start] == b'#' {
172 let after_hash = body_start + 1;
174 let Some(close_hash) = find_from(src, after_hash, "#}}") else {
175 let (line, col) = line_col(src, open);
176 return Err(TemplateError::new(line, col, "unterminated comment"));
177 };
178 cursor = close_hash + 3;
179 continue;
183 }
184
185 let body_trim_start = skip_ws(src, body_start);
187 let raw_kw_end = body_trim_start + 3;
188 if raw_kw_end <= len && &src[body_trim_start..raw_kw_end.min(len)] == "raw" && {
189 let after = raw_kw_end;
191 after >= len
192 || bytes[after] == b' '
193 || bytes[after] == b'\t'
194 || bytes[after] == b'\n'
195 || bytes[after] == b'\r'
196 || (after + 1 < len && &src[after..after + 2] == "}}")
197 || (after + 2 < len && &src[after..after + 3] == "-}}")
198 } {
199 let Some(dir_close) = find_from(src, raw_kw_end, "}}") else {
201 let (line, col) = line_col(src, open);
202 return Err(TemplateError::new(line, col, "unterminated directive"));
203 };
204 let raw_body_start = dir_close + 2;
206 let trim_after_open = dir_close > 0 && bytes[dir_close - 1] == b'-';
207 let _ = trim_after_open; let (raw_end_open, raw_end_close) =
211 find_endraw(src, raw_body_start).ok_or_else(|| {
212 let (line, col) = line_col(src, open);
213 TemplateError::new(line, col, "unterminated `{{ raw }}` block")
214 })?;
215 let raw_content = src[raw_body_start..raw_end_open].to_string();
216 tokens.push(Token::Raw(raw_content));
217 cursor = raw_end_close;
218 continue;
219 }
220
221 let (close_pos, trim_after) = find_directive_close(src, body_start).ok_or_else(|| {
224 let (line, col) = line_col(src, open);
225 TemplateError::new(line, col, "unterminated directive")
226 })?;
227 let body_end = if trim_after { close_pos - 1 } else { close_pos };
228 let body = src[body_start..body_end].trim().to_string();
229 let (line, col) = line_col(src, open);
230 tokens.push(Token::Directive { body, line, col });
231 cursor = close_pos + 2;
232 pending_trim_left = trim_after;
233 }
234
235 Ok(tokens)
236}
237
238fn find_from(s: &str, from: usize, pat: &str) -> Option<usize> {
239 s[from..].find(pat).map(|i| i + from)
240}
241
242fn skip_ws(s: &str, from: usize) -> usize {
243 let bytes = s.as_bytes();
244 let mut i = from;
245 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
246 i += 1;
247 }
248 i
249}
250
251fn line_col(s: &str, offset: usize) -> (usize, usize) {
252 let mut line = 1usize;
253 let mut col = 1usize;
254 for (i, ch) in s.char_indices() {
255 if i >= offset {
256 break;
257 }
258 if ch == '\n' {
259 line += 1;
260 col = 1;
261 } else {
262 col += 1;
263 }
264 }
265 (line, col)
266}
267
268fn find_directive_close(s: &str, start: usize) -> Option<(usize, bool)> {
272 let bytes = s.as_bytes();
273 let mut i = start;
274 let mut in_str = false;
275 let mut str_quote = b'"';
276 while i + 1 < bytes.len() {
277 let b = bytes[i];
278 if in_str {
279 if b == b'\\' {
280 i += 2;
281 continue;
282 }
283 if b == str_quote {
284 in_str = false;
285 }
286 i += 1;
287 continue;
288 }
289 if b == b'"' || b == b'\'' {
290 in_str = true;
291 str_quote = b;
292 i += 1;
293 continue;
294 }
295 if b == b'}' && bytes[i + 1] == b'}' {
296 let trim = i > start && bytes[i - 1] == b'-';
297 return Some((i, trim));
298 }
299 i += 1;
300 }
301 None
302}
303
304fn find_endraw(s: &str, from: usize) -> Option<(usize, usize)> {
307 let mut cursor = from;
308 while let Some(open) = find_from(s, cursor, "{{") {
309 let after = open + 2;
310 let body_start = if s.as_bytes().get(after) == Some(&b'-') {
311 after + 1
312 } else {
313 after
314 };
315 let body_trim_start = skip_ws(s, body_start);
316 let close = find_directive_close(s, body_start)?;
317 let body_end = if close.1 { close.0 - 1 } else { close.0 };
318 let body = s[body_trim_start..body_end].trim();
319 if body == "endraw" {
320 return Some((open, close.0 + 2));
321 }
322 cursor = close.0 + 2;
323 }
324 None
325}
326
327#[derive(Debug, Clone)]
332enum Node {
333 Text(String),
334 Expr {
335 expr: Expr,
336 line: usize,
337 col: usize,
338 },
339 If {
340 branches: Vec<(Expr, Vec<Node>)>,
341 else_branch: Option<Vec<Node>>,
342 line: usize,
343 col: usize,
344 },
345 For {
346 value_var: String,
347 key_var: Option<String>,
348 iter: Expr,
349 body: Vec<Node>,
350 empty: Option<Vec<Node>>,
351 line: usize,
352 col: usize,
353 },
354 Include {
355 path: Expr,
356 with: Option<Vec<(String, Expr)>>,
357 line: usize,
358 col: usize,
359 },
360 LegacyBareInterp {
363 ident: String,
364 },
365}
366
367#[derive(Debug, Clone)]
368enum Expr {
369 Nil,
370 Bool(bool),
371 Int(i64),
372 Float(f64),
373 Str(String),
374 Path(Vec<PathSeg>),
375 Unary(UnOp, Box<Expr>),
376 Binary(BinOp, Box<Expr>, Box<Expr>),
377 Filter(Box<Expr>, String, Vec<Expr>),
378}
379
380#[derive(Debug, Clone)]
381enum PathSeg {
382 Field(String),
383 Index(i64),
384 Key(String),
385}
386
387#[derive(Debug, Clone, Copy)]
388enum UnOp {
389 Not,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq)]
393enum BinOp {
394 Eq,
395 Neq,
396 Lt,
397 Le,
398 Gt,
399 Ge,
400 And,
401 Or,
402}
403
404fn parse(src: &str) -> Result<Vec<Node>, TemplateError> {
409 let tokens = tokenize(src)?;
410 let mut p = Parser {
411 tokens: &tokens,
412 pos: 0,
413 };
414 let nodes = p.parse_block(&[])?;
415 if p.pos < tokens.len() {
416 }
418 Ok(nodes)
419}
420
421struct Parser<'a> {
422 tokens: &'a [Token],
423 pos: usize,
424}
425
426impl<'a> Parser<'a> {
427 fn peek(&self) -> Option<&'a Token> {
428 self.tokens.get(self.pos)
429 }
430
431 fn parse_block(&mut self, stops: &[&str]) -> Result<Vec<Node>, TemplateError> {
432 let mut out = Vec::new();
433 while let Some(tok) = self.peek() {
434 match tok {
435 Token::Text {
436 content,
437 trim_right,
438 trim_left,
439 } => {
440 let mut s = content.clone();
441 if *trim_left {
442 s = trim_leading_line(&s);
443 }
444 if *trim_right {
445 s = trim_trailing_line(&s);
446 }
447 if !s.is_empty() {
448 out.push(Node::Text(s));
449 }
450 self.pos += 1;
451 }
452 Token::Raw(content) => {
453 if !content.is_empty() {
454 out.push(Node::Text(content.clone()));
455 }
456 self.pos += 1;
457 }
458 Token::Directive { body, line, col } => {
459 let (line, col) = (*line, *col);
460 let body = body.clone();
461 let first_word = first_word(&body);
463 if stops.contains(&first_word) {
464 return Ok(out);
465 }
466 self.pos += 1;
467
468 if body == "end" {
469 return Err(TemplateError::new(line, col, "unexpected `{{ end }}`"));
470 }
471 if body == "else" {
472 return Err(TemplateError::new(line, col, "unexpected `{{ else }}`"));
473 }
474 if first_word == "elif" {
475 return Err(TemplateError::new(line, col, "unexpected `{{ elif }}`"));
476 }
477
478 if first_word == "if" {
479 let cond_src = body[2..].trim();
480 let cond = parse_expr(cond_src, line, col)?;
481 let node = self.parse_if(cond, line, col)?;
482 out.push(node);
483 } else if first_word == "for" {
484 let node = self.parse_for(body[3..].trim(), line, col)?;
485 out.push(node);
486 } else if first_word == "include" {
487 let node = parse_include(body[7..].trim(), line, col)?;
488 out.push(node);
489 } else if is_bare_ident(&body) {
490 out.push(Node::LegacyBareInterp { ident: body });
491 } else {
492 let expr = parse_expr(&body, line, col)?;
493 out.push(Node::Expr { expr, line, col });
494 }
495 }
496 }
497 }
498 Ok(out)
499 }
500
501 fn parse_if(
502 &mut self,
503 first_cond: Expr,
504 line: usize,
505 col: usize,
506 ) -> Result<Node, TemplateError> {
507 let mut branches = Vec::new();
508 let mut else_branch = None;
509 let mut cur_cond = first_cond;
510 loop {
511 let body = self.parse_block(&["end", "else", "elif"])?;
512 branches.push((cur_cond, body));
513 let tok = self.peek().cloned();
515 match tok {
516 Some(Token::Directive {
517 body: tbody,
518 line: tline,
519 col: tcol,
520 }) => {
521 let fw = first_word(&tbody);
522 self.pos += 1;
523 match fw {
524 "end" => break,
525 "else" => {
526 let eb = self.parse_block(&["end"])?;
527 else_branch = Some(eb);
528 match self.peek() {
530 Some(Token::Directive { body, .. }) if body == "end" => {
531 self.pos += 1;
532 }
533 _ => {
534 return Err(TemplateError::new(
535 tline,
536 tcol,
537 "`{{ else }}` missing matching `{{ end }}`",
538 ));
539 }
540 }
541 break;
542 }
543 "elif" => {
544 let cond = parse_expr(tbody[4..].trim(), tline, tcol)?;
545 cur_cond = cond;
546 continue;
547 }
548 _ => unreachable!(),
549 }
550 }
551 _ => {
552 return Err(TemplateError::new(
553 line,
554 col,
555 "`{{ if }}` missing matching `{{ end }}`",
556 ));
557 }
558 }
559 }
560 Ok(Node::If {
561 branches,
562 else_branch,
563 line,
564 col,
565 })
566 }
567
568 fn parse_for(&mut self, spec: &str, line: usize, col: usize) -> Result<Node, TemplateError> {
569 let (head, iter_src) = match split_once_keyword(spec, " in ") {
571 Some(p) => p,
572 None => return Err(TemplateError::new(line, col, "expected `in` in for-loop")),
573 };
574 let head = head.trim();
575 let iter_src = iter_src.trim();
576 let (value_var, key_var) = if let Some((a, b)) = head.split_once(',') {
577 let a = a.trim().to_string();
578 let b = b.trim().to_string();
579 if !is_ident(&a) || !is_ident(&b) {
580 return Err(TemplateError::new(line, col, "invalid for-loop variables"));
581 }
582 (b, Some(a)) } else {
584 if !is_ident(head) {
585 return Err(TemplateError::new(line, col, "invalid for-loop variable"));
586 }
587 (head.to_string(), None)
588 };
589 let iter = parse_expr(iter_src, line, col)?;
590 let body = self.parse_block(&["end", "else"])?;
591 let (empty, _) = match self.peek().cloned() {
592 Some(Token::Directive { body: tbody, .. }) => {
593 let fw = first_word(&tbody);
594 self.pos += 1;
595 if fw == "end" {
596 (None, ())
597 } else if fw == "else" {
598 let empty_body = self.parse_block(&["end"])?;
599 match self.peek() {
600 Some(Token::Directive { body, .. }) if body == "end" => {
601 self.pos += 1;
602 }
603 _ => {
604 return Err(TemplateError::new(
605 line,
606 col,
607 "`{{ else }}` missing matching `{{ end }}`",
608 ));
609 }
610 }
611 (Some(empty_body), ())
612 } else {
613 unreachable!()
614 }
615 }
616 _ => {
617 return Err(TemplateError::new(
618 line,
619 col,
620 "`{{ for }}` missing matching `{{ end }}`",
621 ));
622 }
623 };
624 Ok(Node::For {
625 value_var,
626 key_var,
627 iter,
628 body,
629 empty,
630 line,
631 col,
632 })
633 }
634}
635
636fn parse_include(spec: &str, line: usize, col: usize) -> Result<Node, TemplateError> {
637 let (path_src, with_src) = match split_once_keyword(spec, " with ") {
639 Some((a, b)) => (a.trim(), Some(b.trim())),
640 None => (spec.trim(), None),
641 };
642 let path = parse_expr(path_src, line, col)?;
643 let with = if let Some(src) = with_src {
644 Some(parse_dict_literal(src, line, col)?)
645 } else {
646 None
647 };
648 Ok(Node::Include {
649 path,
650 with,
651 line,
652 col,
653 })
654}
655
656fn parse_dict_literal(
657 src: &str,
658 line: usize,
659 col: usize,
660) -> Result<Vec<(String, Expr)>, TemplateError> {
661 let s = src.trim();
662 if !s.starts_with('{') || !s.ends_with('}') {
663 return Err(TemplateError::new(
664 line,
665 col,
666 "expected `{ ... }` after `with`",
667 ));
668 }
669 let inner = &s[1..s.len() - 1];
670 let mut pairs = Vec::new();
671 for chunk in split_top_level(inner, ',') {
672 let chunk = chunk.trim();
673 if chunk.is_empty() {
674 continue;
675 }
676 let (k, v) = match split_once_top_level(chunk, ':') {
677 Some(p) => p,
678 None => {
679 return Err(TemplateError::new(
680 line,
681 col,
682 "expected `key: value` in include bindings",
683 ));
684 }
685 };
686 let k = k.trim();
687 if !is_ident(k) {
688 return Err(TemplateError::new(line, col, "invalid include binding key"));
689 }
690 let v = parse_expr(v.trim(), line, col)?;
691 pairs.push((k.to_string(), v));
692 }
693 Ok(pairs)
694}
695
696fn first_word(s: &str) -> &str {
697 s.split(|c: char| c.is_whitespace()).next().unwrap_or("")
698}
699
700fn is_ident(s: &str) -> bool {
701 let mut chars = s.chars();
702 match chars.next() {
703 Some(c) if c.is_alphabetic() || c == '_' => {}
704 _ => return false,
705 }
706 chars.all(|c| c.is_alphanumeric() || c == '_')
707}
708
709fn is_bare_ident(s: &str) -> bool {
710 is_ident(s)
713}
714
715fn trim_leading_line(s: &str) -> String {
716 let mut i = 0;
718 let bytes = s.as_bytes();
719 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
720 i += 1;
721 }
722 if i < bytes.len() && bytes[i] == b'\n' {
723 return s[i + 1..].to_string();
724 }
725 if i < bytes.len() && bytes[i] == b'\r' {
726 if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
727 return s[i + 2..].to_string();
728 }
729 return s[i + 1..].to_string();
730 }
731 s[i..].to_string()
733}
734
735fn trim_trailing_line(s: &str) -> String {
736 let bytes = s.as_bytes();
737 let mut i = bytes.len();
738 while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
739 i -= 1;
740 }
741 if i > 0 && bytes[i - 1] == b'\n' {
742 let end = i - 1;
744 let end = if end > 0 && bytes[end - 1] == b'\r' {
745 end - 1
746 } else {
747 end
748 };
749 return s[..end].to_string();
750 }
751 s[..i].to_string()
753}
754
755fn parse_expr(src: &str, line: usize, col: usize) -> Result<Expr, TemplateError> {
758 let tokens = tokenize_expr(src, line, col)?;
759 let mut p = ExprParser {
760 toks: &tokens,
761 pos: 0,
762 line,
763 col,
764 };
765 let e = p.parse_filter()?;
766 if p.pos < tokens.len() {
767 return Err(TemplateError::new(
768 line,
769 col,
770 format!("unexpected token `{:?}` in expression", p.toks[p.pos]),
771 ));
772 }
773 Ok(e)
774}
775
776#[derive(Debug, Clone, PartialEq)]
777enum EToken {
778 Ident(String),
779 Str(String),
780 Int(i64),
781 Float(f64),
782 LParen,
783 RParen,
784 LBracket,
785 RBracket,
786 Dot,
787 Comma,
788 Colon,
789 Pipe,
790 Bang,
791 EqEq,
792 BangEq,
793 Lt,
794 Le,
795 Gt,
796 Ge,
797 AndKw,
798 OrKw,
799 NotKw,
800 True,
801 False,
802 Nil,
803}
804
805fn tokenize_expr(src: &str, line: usize, col: usize) -> Result<Vec<EToken>, TemplateError> {
806 let bytes = src.as_bytes();
807 let mut toks = Vec::new();
808 let mut i = 0;
809 while i < bytes.len() {
810 let b = bytes[i];
811 if b.is_ascii_whitespace() {
812 i += 1;
813 continue;
814 }
815 match b {
816 b'(' => {
817 toks.push(EToken::LParen);
818 i += 1;
819 }
820 b')' => {
821 toks.push(EToken::RParen);
822 i += 1;
823 }
824 b'[' => {
825 toks.push(EToken::LBracket);
826 i += 1;
827 }
828 b']' => {
829 toks.push(EToken::RBracket);
830 i += 1;
831 }
832 b'.' => {
833 toks.push(EToken::Dot);
834 i += 1;
835 }
836 b',' => {
837 toks.push(EToken::Comma);
838 i += 1;
839 }
840 b':' => {
841 toks.push(EToken::Colon);
842 i += 1;
843 }
844 b'|' => {
845 if i + 1 < bytes.len() && bytes[i + 1] == b'|' {
846 toks.push(EToken::OrKw);
847 i += 2;
848 } else {
849 toks.push(EToken::Pipe);
850 i += 1;
851 }
852 }
853 b'&' => {
854 if i + 1 < bytes.len() && bytes[i + 1] == b'&' {
855 toks.push(EToken::AndKw);
856 i += 2;
857 } else {
858 return Err(TemplateError::new(line, col, "unexpected `&`"));
859 }
860 }
861 b'!' => {
862 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
863 toks.push(EToken::BangEq);
864 i += 2;
865 } else {
866 toks.push(EToken::Bang);
867 i += 1;
868 }
869 }
870 b'=' => {
871 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
872 toks.push(EToken::EqEq);
873 i += 2;
874 } else {
875 return Err(TemplateError::new(line, col, "unexpected `=` (use `==`)"));
876 }
877 }
878 b'<' => {
879 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
880 toks.push(EToken::Le);
881 i += 2;
882 } else {
883 toks.push(EToken::Lt);
884 i += 1;
885 }
886 }
887 b'>' => {
888 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
889 toks.push(EToken::Ge);
890 i += 2;
891 } else {
892 toks.push(EToken::Gt);
893 i += 1;
894 }
895 }
896 b'"' | b'\'' => {
897 let quote = b;
898 let start = i + 1;
899 let mut j = start;
900 let mut out = String::new();
901 while j < bytes.len() && bytes[j] != quote {
902 if bytes[j] == b'\\' && j + 1 < bytes.len() {
903 match bytes[j + 1] {
904 b'n' => out.push('\n'),
905 b't' => out.push('\t'),
906 b'r' => out.push('\r'),
907 b'\\' => out.push('\\'),
908 b'"' => out.push('"'),
909 b'\'' => out.push('\''),
910 c => out.push(c as char),
911 }
912 j += 2;
913 continue;
914 }
915 out.push(bytes[j] as char);
916 j += 1;
917 }
918 if j >= bytes.len() {
919 return Err(TemplateError::new(line, col, "unterminated string literal"));
920 }
921 toks.push(EToken::Str(out));
922 i = j + 1;
923 }
924 b'0'..=b'9' | b'-'
925 if b != b'-' || (i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) =>
926 {
927 let start = i;
928 if bytes[i] == b'-' {
929 i += 1;
930 }
931 let mut is_float = false;
932 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
933 if bytes[i] == b'.' {
934 if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
936 is_float = true;
937 i += 1;
938 continue;
939 } else {
940 break;
941 }
942 }
943 i += 1;
944 }
945 let lex = &src[start..i];
946 if is_float {
947 let v: f64 = lex.parse().map_err(|_| {
948 TemplateError::new(line, col, format!("invalid number `{lex}`"))
949 })?;
950 toks.push(EToken::Float(v));
951 } else {
952 let v: i64 = lex.parse().map_err(|_| {
953 TemplateError::new(line, col, format!("invalid integer `{lex}`"))
954 })?;
955 toks.push(EToken::Int(v));
956 }
957 }
958 c if c.is_ascii_alphabetic() || c == b'_' => {
959 let start = i;
960 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
961 i += 1;
962 }
963 let word = &src[start..i];
964 match word {
965 "true" => toks.push(EToken::True),
966 "false" => toks.push(EToken::False),
967 "nil" => toks.push(EToken::Nil),
968 "and" => toks.push(EToken::AndKw),
969 "or" => toks.push(EToken::OrKw),
970 "not" => toks.push(EToken::NotKw),
971 other => toks.push(EToken::Ident(other.to_string())),
972 }
973 }
974 _ => {
975 return Err(TemplateError::new(
976 line,
977 col,
978 format!("unexpected character `{}` in expression", b as char),
979 ));
980 }
981 }
982 }
983 Ok(toks)
984}
985
986struct ExprParser<'a> {
987 toks: &'a [EToken],
988 pos: usize,
989 line: usize,
990 col: usize,
991}
992
993impl<'a> ExprParser<'a> {
994 fn peek(&self) -> Option<&EToken> {
995 self.toks.get(self.pos)
996 }
997 fn eat(&mut self, t: &EToken) -> bool {
998 if self.peek() == Some(t) {
999 self.pos += 1;
1000 true
1001 } else {
1002 false
1003 }
1004 }
1005 fn err(&self, m: impl Into<String>) -> TemplateError {
1006 TemplateError::new(self.line, self.col, m)
1007 }
1008
1009 fn parse_filter(&mut self) -> Result<Expr, TemplateError> {
1010 let mut left = self.parse_or()?;
1011 while self.eat(&EToken::Pipe) {
1012 let name = match self.peek() {
1013 Some(EToken::Ident(n)) => n.clone(),
1014 _ => return Err(self.err("expected filter name after `|`")),
1015 };
1016 self.pos += 1;
1017 let mut args = Vec::new();
1018 if self.eat(&EToken::Colon) {
1019 loop {
1020 let a = self.parse_or()?;
1021 args.push(a);
1022 if !self.eat(&EToken::Comma) {
1023 break;
1024 }
1025 }
1026 }
1027 left = Expr::Filter(Box::new(left), name, args);
1028 }
1029 Ok(left)
1030 }
1031
1032 fn parse_or(&mut self) -> Result<Expr, TemplateError> {
1033 let mut left = self.parse_and()?;
1034 while self.eat(&EToken::OrKw) {
1035 let right = self.parse_and()?;
1036 left = Expr::Binary(BinOp::Or, Box::new(left), Box::new(right));
1037 }
1038 Ok(left)
1039 }
1040
1041 fn parse_and(&mut self) -> Result<Expr, TemplateError> {
1042 let mut left = self.parse_not()?;
1043 while self.eat(&EToken::AndKw) {
1044 let right = self.parse_not()?;
1045 left = Expr::Binary(BinOp::And, Box::new(left), Box::new(right));
1046 }
1047 Ok(left)
1048 }
1049
1050 fn parse_not(&mut self) -> Result<Expr, TemplateError> {
1051 if self.eat(&EToken::Bang) || self.eat(&EToken::NotKw) {
1052 let inner = self.parse_not()?;
1053 return Ok(Expr::Unary(UnOp::Not, Box::new(inner)));
1054 }
1055 self.parse_cmp()
1056 }
1057
1058 fn parse_cmp(&mut self) -> Result<Expr, TemplateError> {
1059 let left = self.parse_unary()?;
1060 let op = match self.peek() {
1061 Some(EToken::EqEq) => Some(BinOp::Eq),
1062 Some(EToken::BangEq) => Some(BinOp::Neq),
1063 Some(EToken::Lt) => Some(BinOp::Lt),
1064 Some(EToken::Le) => Some(BinOp::Le),
1065 Some(EToken::Gt) => Some(BinOp::Gt),
1066 Some(EToken::Ge) => Some(BinOp::Ge),
1067 _ => None,
1068 };
1069 if let Some(op) = op {
1070 self.pos += 1;
1071 let right = self.parse_unary()?;
1072 return Ok(Expr::Binary(op, Box::new(left), Box::new(right)));
1073 }
1074 Ok(left)
1075 }
1076
1077 fn parse_unary(&mut self) -> Result<Expr, TemplateError> {
1078 self.parse_primary()
1079 }
1080
1081 fn parse_primary(&mut self) -> Result<Expr, TemplateError> {
1082 let tok = self
1083 .peek()
1084 .cloned()
1085 .ok_or_else(|| self.err("expected expression"))?;
1086 self.pos += 1;
1087 let base = match tok {
1088 EToken::Nil => Expr::Nil,
1089 EToken::True => Expr::Bool(true),
1090 EToken::False => Expr::Bool(false),
1091 EToken::Int(n) => Expr::Int(n),
1092 EToken::Float(f) => Expr::Float(f),
1093 EToken::Str(s) => Expr::Str(s),
1094 EToken::LParen => {
1095 let e = self.parse_or()?;
1096 if !self.eat(&EToken::RParen) {
1097 return Err(self.err("expected `)`"));
1098 }
1099 e
1100 }
1101 EToken::Ident(name) => self.parse_path(name)?,
1102 EToken::Bang | EToken::NotKw => {
1103 let inner = self.parse_primary()?;
1104 Expr::Unary(UnOp::Not, Box::new(inner))
1105 }
1106 other => return Err(self.err(format!("unexpected token `{:?}`", other))),
1107 };
1108 Ok(base)
1109 }
1110
1111 fn parse_path(&mut self, head: String) -> Result<Expr, TemplateError> {
1112 let mut segs = vec![PathSeg::Field(head)];
1113 loop {
1114 match self.peek() {
1115 Some(EToken::Dot) => {
1116 self.pos += 1;
1117 match self.peek().cloned() {
1118 Some(EToken::Ident(n)) => {
1119 self.pos += 1;
1120 segs.push(PathSeg::Field(n));
1121 }
1122 _ => return Err(self.err("expected identifier after `.`")),
1123 }
1124 }
1125 Some(EToken::LBracket) => {
1126 self.pos += 1;
1127 match self.peek().cloned() {
1128 Some(EToken::Int(n)) => {
1129 self.pos += 1;
1130 segs.push(PathSeg::Index(n));
1131 }
1132 Some(EToken::Str(s)) => {
1133 self.pos += 1;
1134 segs.push(PathSeg::Key(s));
1135 }
1136 _ => return Err(self.err("expected integer or string inside `[...]`")),
1137 }
1138 if !self.eat(&EToken::RBracket) {
1139 return Err(self.err("expected `]`"));
1140 }
1141 }
1142 _ => break,
1143 }
1144 }
1145 Ok(Expr::Path(segs))
1146 }
1147}
1148
1149#[derive(Default, Debug, Clone)]
1154struct Scope<'a> {
1155 root: Option<&'a BTreeMap<String, VmValue>>,
1157 overrides: Vec<BTreeMap<String, VmValue>>,
1159}
1160
1161impl<'a> Scope<'a> {
1162 fn new(root: Option<&'a BTreeMap<String, VmValue>>) -> Self {
1163 Self {
1164 root,
1165 overrides: Vec::new(),
1166 }
1167 }
1168
1169 fn lookup(&self, name: &str) -> Option<VmValue> {
1170 for layer in self.overrides.iter().rev() {
1171 if let Some(v) = layer.get(name) {
1172 return Some(v.clone());
1173 }
1174 }
1175 self.root.and_then(|m| m.get(name)).cloned()
1176 }
1177
1178 fn push(&mut self, layer: BTreeMap<String, VmValue>) {
1179 self.overrides.push(layer);
1180 }
1181
1182 fn pop(&mut self) {
1183 self.overrides.pop();
1184 }
1185
1186 fn flatten(&self) -> BTreeMap<String, VmValue> {
1189 let mut out = BTreeMap::new();
1190 if let Some(r) = self.root {
1191 for (k, v) in r.iter() {
1192 out.insert(k.clone(), v.clone());
1193 }
1194 }
1195 for layer in &self.overrides {
1196 for (k, v) in layer {
1197 out.insert(k.clone(), v.clone());
1198 }
1199 }
1200 out
1201 }
1202}
1203
1204struct RenderCtx {
1205 base: Option<PathBuf>,
1206 include_stack: Vec<PathBuf>,
1207 current_path: Option<PathBuf>,
1208}
1209
1210fn render_nodes(
1211 nodes: &[Node],
1212 scope: &mut Scope<'_>,
1213 rc: &mut RenderCtx,
1214 out: &mut String,
1215) -> Result<(), TemplateError> {
1216 for n in nodes {
1217 render_node(n, scope, rc, out)?;
1218 }
1219 Ok(())
1220}
1221
1222fn render_node(
1223 node: &Node,
1224 scope: &mut Scope<'_>,
1225 rc: &mut RenderCtx,
1226 out: &mut String,
1227) -> Result<(), TemplateError> {
1228 match node {
1229 Node::Text(s) => out.push_str(s),
1230 Node::Expr { expr, line, col } => {
1231 let v = eval_expr(expr, scope, *line, *col)?;
1232 out.push_str(&display_value(&v));
1233 }
1234 Node::LegacyBareInterp { ident } => {
1235 match scope.lookup(ident) {
1236 Some(v) => out.push_str(&display_value(&v)),
1237 None => {
1238 out.push_str("{{");
1240 out.push_str(ident);
1241 out.push_str("}}");
1242 }
1243 }
1244 }
1245 Node::If {
1246 branches,
1247 else_branch,
1248 line,
1249 col,
1250 } => {
1251 let mut matched = false;
1252 for (cond, body) in branches {
1253 let v = eval_expr(cond, scope, *line, *col)?;
1254 if truthy(&v) {
1255 render_nodes(body, scope, rc, out)?;
1256 matched = true;
1257 break;
1258 }
1259 }
1260 if !matched {
1261 if let Some(eb) = else_branch {
1262 render_nodes(eb, scope, rc, out)?;
1263 }
1264 }
1265 }
1266 Node::For {
1267 value_var,
1268 key_var,
1269 iter,
1270 body,
1271 empty,
1272 line,
1273 col,
1274 } => {
1275 let v = eval_expr(iter, scope, *line, *col)?;
1276 let items: Vec<(VmValue, VmValue)> =
1277 iterable_items(&v).map_err(|m| TemplateError::new(*line, *col, m))?;
1278 if items.is_empty() {
1279 if let Some(eb) = empty {
1280 render_nodes(eb, scope, rc, out)?;
1281 }
1282 } else {
1283 let length = items.len() as i64;
1284 for (idx, (k, val)) in items.iter().enumerate() {
1285 let mut layer: BTreeMap<String, VmValue> = BTreeMap::new();
1286 layer.insert(value_var.clone(), val.clone());
1287 if let Some(kv) = key_var {
1288 layer.insert(kv.clone(), k.clone());
1289 }
1290 let mut loop_map: BTreeMap<String, VmValue> = BTreeMap::new();
1291 loop_map.insert("index".into(), VmValue::Int(idx as i64 + 1));
1292 loop_map.insert("index0".into(), VmValue::Int(idx as i64));
1293 loop_map.insert("first".into(), VmValue::Bool(idx == 0));
1294 loop_map.insert("last".into(), VmValue::Bool(idx as i64 == length - 1));
1295 loop_map.insert("length".into(), VmValue::Int(length));
1296 layer.insert("loop".into(), VmValue::Dict(Rc::new(loop_map)));
1297 scope.push(layer);
1298 let res = render_nodes(body, scope, rc, out);
1299 scope.pop();
1300 res?;
1301 }
1302 }
1303 }
1304 Node::Include {
1305 path,
1306 with,
1307 line,
1308 col,
1309 } => {
1310 let path_val = eval_expr(path, scope, *line, *col)?;
1311 let path_str = match path_val {
1312 VmValue::String(s) => s.to_string(),
1313 other => {
1314 return Err(TemplateError::new(
1315 *line,
1316 *col,
1317 format!("include path must be a string (got {})", other.type_name()),
1318 ))
1319 }
1320 };
1321 let resolved: PathBuf = if Path::new(&path_str).is_absolute() {
1324 PathBuf::from(&path_str)
1325 } else if let Some(base) = &rc.base {
1326 base.join(&path_str)
1327 } else {
1328 crate::stdlib::process::resolve_source_asset_path(&path_str)
1329 };
1330 let canonical = resolved.canonicalize().unwrap_or(resolved.clone());
1331 if rc.include_stack.iter().any(|p| p == &canonical) {
1332 let chain = rc
1333 .include_stack
1334 .iter()
1335 .map(|p| p.display().to_string())
1336 .collect::<Vec<_>>()
1337 .join(" → ");
1338 return Err(TemplateError::new(
1339 *line,
1340 *col,
1341 format!(
1342 "circular include detected: {chain} → {}",
1343 canonical.display()
1344 ),
1345 ));
1346 }
1347 if rc.include_stack.len() > 32 {
1348 return Err(TemplateError::new(
1349 *line,
1350 *col,
1351 "include depth exceeded (32 levels)",
1352 ));
1353 }
1354 let contents = std::fs::read_to_string(&resolved).map_err(|e| {
1355 TemplateError::new(
1356 *line,
1357 *col,
1358 format!(
1359 "failed to read included template {}: {e}",
1360 resolved.display()
1361 ),
1362 )
1363 })?;
1364 let new_base = resolved.parent().map(Path::to_path_buf);
1365 let mut child_bindings = scope.flatten();
1367 if let Some(pairs) = with {
1368 for (k, e) in pairs {
1369 let v = eval_expr(e, scope, *line, *col)?;
1370 child_bindings.insert(k.clone(), v);
1371 }
1372 }
1373 let child_nodes = parse(&contents).map_err(|mut e| {
1374 if e.path.is_none() {
1375 e.path = Some(resolved.clone());
1376 }
1377 e
1378 })?;
1379 let mut child_scope = Scope::new(Some(&child_bindings));
1380 let saved_base = rc.base.clone();
1381 let saved_current = rc.current_path.clone();
1382 rc.base = new_base;
1383 rc.current_path = Some(resolved.clone());
1384 rc.include_stack.push(canonical);
1385 let res = render_nodes(&child_nodes, &mut child_scope, rc, out);
1386 rc.include_stack.pop();
1387 rc.base = saved_base;
1388 rc.current_path = saved_current;
1389 res?;
1390 }
1391 }
1392 Ok(())
1393}
1394
1395fn eval_expr(
1396 expr: &Expr,
1397 scope: &Scope<'_>,
1398 line: usize,
1399 col: usize,
1400) -> Result<VmValue, TemplateError> {
1401 match expr {
1402 Expr::Nil => Ok(VmValue::Nil),
1403 Expr::Bool(b) => Ok(VmValue::Bool(*b)),
1404 Expr::Int(n) => Ok(VmValue::Int(*n)),
1405 Expr::Float(f) => Ok(VmValue::Float(*f)),
1406 Expr::Str(s) => Ok(VmValue::String(Rc::from(s.as_str()))),
1407 Expr::Path(segs) => Ok(resolve_path(segs, scope)),
1408 Expr::Unary(UnOp::Not, inner) => {
1409 let v = eval_expr(inner, scope, line, col)?;
1410 Ok(VmValue::Bool(!truthy(&v)))
1411 }
1412 Expr::Binary(op, a, b) => {
1413 match op {
1415 BinOp::And => {
1416 let av = eval_expr(a, scope, line, col)?;
1417 if !truthy(&av) {
1418 return Ok(av);
1419 }
1420 return eval_expr(b, scope, line, col);
1421 }
1422 BinOp::Or => {
1423 let av = eval_expr(a, scope, line, col)?;
1424 if truthy(&av) {
1425 return Ok(av);
1426 }
1427 return eval_expr(b, scope, line, col);
1428 }
1429 _ => {}
1430 }
1431 let av = eval_expr(a, scope, line, col)?;
1432 let bv = eval_expr(b, scope, line, col)?;
1433 Ok(apply_cmp(*op, &av, &bv))
1434 }
1435 Expr::Filter(inner, name, args) => {
1436 let v = eval_expr(inner, scope, line, col)?;
1437 let arg_vals = args
1438 .iter()
1439 .map(|e| eval_expr(e, scope, line, col))
1440 .collect::<Result<Vec<_>, _>>()?;
1441 apply_filter(name, &v, &arg_vals, line, col)
1442 }
1443 }
1444}
1445
1446fn resolve_path(segs: &[PathSeg], scope: &Scope<'_>) -> VmValue {
1447 let mut cur: VmValue = match segs.first() {
1448 Some(PathSeg::Field(n)) => match scope.lookup(n) {
1449 Some(v) => v,
1450 None => return VmValue::Nil,
1451 },
1452 _ => return VmValue::Nil,
1453 };
1454 for seg in &segs[1..] {
1455 cur = match (seg, &cur) {
1456 (PathSeg::Field(n), VmValue::Dict(d)) => d.get(n).cloned().unwrap_or(VmValue::Nil),
1457 (PathSeg::Key(k), VmValue::Dict(d)) => d.get(k).cloned().unwrap_or(VmValue::Nil),
1458 (PathSeg::Index(i), VmValue::List(items)) => {
1459 let idx = if *i < 0 { items.len() as i64 + *i } else { *i };
1460 if idx < 0 || (idx as usize) >= items.len() {
1461 VmValue::Nil
1462 } else {
1463 items[idx as usize].clone()
1464 }
1465 }
1466 (PathSeg::Index(i), VmValue::String(s)) => {
1467 let chars: Vec<char> = s.chars().collect();
1468 let idx = if *i < 0 { chars.len() as i64 + *i } else { *i };
1469 if idx < 0 || (idx as usize) >= chars.len() {
1470 VmValue::Nil
1471 } else {
1472 VmValue::String(Rc::from(chars[idx as usize].to_string()))
1473 }
1474 }
1475 _ => VmValue::Nil,
1476 };
1477 }
1478 cur
1479}
1480
1481fn truthy(v: &VmValue) -> bool {
1482 match v {
1483 VmValue::Nil => false,
1484 VmValue::Bool(b) => *b,
1485 VmValue::Int(n) => *n != 0,
1486 VmValue::Float(f) => *f != 0.0,
1487 VmValue::String(s) => !s.trim().is_empty(),
1488 VmValue::List(items) => !items.is_empty(),
1489 VmValue::Dict(d) => !d.is_empty(),
1490 _ => true,
1491 }
1492}
1493
1494fn apply_cmp(op: BinOp, a: &VmValue, b: &VmValue) -> VmValue {
1495 match op {
1496 BinOp::Eq => VmValue::Bool(values_equal(a, b)),
1497 BinOp::Neq => VmValue::Bool(!values_equal(a, b)),
1498 BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => {
1499 let ord = compare(a, b);
1500 match (op, ord) {
1501 (BinOp::Lt, Some(o)) => VmValue::Bool(o == std::cmp::Ordering::Less),
1502 (BinOp::Le, Some(o)) => VmValue::Bool(o != std::cmp::Ordering::Greater),
1503 (BinOp::Gt, Some(o)) => VmValue::Bool(o == std::cmp::Ordering::Greater),
1504 (BinOp::Ge, Some(o)) => VmValue::Bool(o != std::cmp::Ordering::Less),
1505 _ => VmValue::Bool(false),
1506 }
1507 }
1508 BinOp::And | BinOp::Or => unreachable!(),
1509 }
1510}
1511
1512fn compare(a: &VmValue, b: &VmValue) -> Option<std::cmp::Ordering> {
1513 match (a, b) {
1514 (VmValue::Int(x), VmValue::Int(y)) => Some(x.cmp(y)),
1515 (VmValue::Float(x), VmValue::Float(y)) => x.partial_cmp(y),
1516 (VmValue::Int(x), VmValue::Float(y)) => (*x as f64).partial_cmp(y),
1517 (VmValue::Float(x), VmValue::Int(y)) => x.partial_cmp(&(*y as f64)),
1518 (VmValue::String(x), VmValue::String(y)) => Some(x.as_ref().cmp(y.as_ref())),
1519 _ => None,
1520 }
1521}
1522
1523fn iterable_items(v: &VmValue) -> Result<Vec<(VmValue, VmValue)>, String> {
1524 match v {
1525 VmValue::List(items) => Ok(items
1526 .iter()
1527 .enumerate()
1528 .map(|(i, it)| (VmValue::Int(i as i64), it.clone()))
1529 .collect()),
1530 VmValue::Dict(d) => Ok(d
1531 .iter()
1532 .map(|(k, v)| (VmValue::String(Rc::from(k.as_str())), v.clone()))
1533 .collect()),
1534 VmValue::Set(items) => Ok(items
1535 .iter()
1536 .enumerate()
1537 .map(|(i, it)| (VmValue::Int(i as i64), it.clone()))
1538 .collect()),
1539 VmValue::Range(r) => {
1540 let mut out = Vec::new();
1541 let len = r.len();
1542 for i in 0..len {
1543 if let Some(v) = r.get(i) {
1544 out.push((VmValue::Int(i), VmValue::Int(v)));
1545 }
1546 }
1547 Ok(out)
1548 }
1549 VmValue::Nil => Ok(Vec::new()),
1550 other => Err(format!(
1551 "cannot iterate over {} — expected list, dict, set, or range",
1552 other.type_name()
1553 )),
1554 }
1555}
1556
1557fn display_value(v: &VmValue) -> String {
1558 match v {
1559 VmValue::Nil => String::new(), other => other.display(),
1561 }
1562}
1563
1564fn apply_filter(
1569 name: &str,
1570 v: &VmValue,
1571 args: &[VmValue],
1572 line: usize,
1573 col: usize,
1574) -> Result<VmValue, TemplateError> {
1575 let bad_arity = || {
1576 TemplateError::new(
1577 line,
1578 col,
1579 format!("filter `{name}` got wrong number of arguments"),
1580 )
1581 };
1582 let need = |n: usize, args: &[VmValue]| -> Result<(), TemplateError> {
1583 if args.len() == n {
1584 Ok(())
1585 } else {
1586 Err(bad_arity())
1587 }
1588 };
1589 let str_of = |v: &VmValue| -> String { display_value(v) };
1590 match name {
1591 "upper" => {
1592 need(0, args)?;
1593 Ok(VmValue::String(Rc::from(str_of(v).to_uppercase())))
1594 }
1595 "lower" => {
1596 need(0, args)?;
1597 Ok(VmValue::String(Rc::from(str_of(v).to_lowercase())))
1598 }
1599 "trim" => {
1600 need(0, args)?;
1601 Ok(VmValue::String(Rc::from(str_of(v).trim())))
1602 }
1603 "capitalize" => {
1604 need(0, args)?;
1605 let s = str_of(v);
1606 let mut out = String::with_capacity(s.len());
1607 let mut chars = s.chars();
1608 if let Some(c) = chars.next() {
1609 out.extend(c.to_uppercase());
1610 }
1611 for c in chars {
1612 out.extend(c.to_lowercase());
1613 }
1614 Ok(VmValue::String(Rc::from(out)))
1615 }
1616 "title" => {
1617 need(0, args)?;
1618 let s = str_of(v);
1619 let mut out = String::with_capacity(s.len());
1620 let mut at_start = true;
1621 for c in s.chars() {
1622 if c.is_whitespace() {
1623 at_start = true;
1624 out.push(c);
1625 } else if at_start {
1626 out.extend(c.to_uppercase());
1627 at_start = false;
1628 } else {
1629 out.extend(c.to_lowercase());
1630 }
1631 }
1632 Ok(VmValue::String(Rc::from(out)))
1633 }
1634 "length" => {
1635 need(0, args)?;
1636 let n: i64 = match v {
1637 VmValue::String(s) => s.chars().count() as i64,
1638 VmValue::List(items) => items.len() as i64,
1639 VmValue::Set(items) => items.len() as i64,
1640 VmValue::Dict(d) => d.len() as i64,
1641 VmValue::Range(r) => r.len(),
1642 VmValue::Nil => 0,
1643 other => {
1644 return Err(TemplateError::new(
1645 line,
1646 col,
1647 format!("`length` not defined for {}", other.type_name()),
1648 ))
1649 }
1650 };
1651 Ok(VmValue::Int(n))
1652 }
1653 "first" => {
1654 need(0, args)?;
1655 Ok(match v {
1656 VmValue::List(items) => items.first().cloned().unwrap_or(VmValue::Nil),
1657 VmValue::Set(items) => items.first().cloned().unwrap_or(VmValue::Nil),
1658 VmValue::String(s) => s
1659 .chars()
1660 .next()
1661 .map(|c| VmValue::String(Rc::from(c.to_string())))
1662 .unwrap_or(VmValue::Nil),
1663 _ => VmValue::Nil,
1664 })
1665 }
1666 "last" => {
1667 need(0, args)?;
1668 Ok(match v {
1669 VmValue::List(items) => items.last().cloned().unwrap_or(VmValue::Nil),
1670 VmValue::Set(items) => items.last().cloned().unwrap_or(VmValue::Nil),
1671 VmValue::String(s) => s
1672 .chars()
1673 .last()
1674 .map(|c| VmValue::String(Rc::from(c.to_string())))
1675 .unwrap_or(VmValue::Nil),
1676 _ => VmValue::Nil,
1677 })
1678 }
1679 "reverse" => {
1680 need(0, args)?;
1681 Ok(match v {
1682 VmValue::List(items) => {
1683 let mut out: Vec<VmValue> = items.as_ref().clone();
1684 out.reverse();
1685 VmValue::List(Rc::new(out))
1686 }
1687 VmValue::String(s) => {
1688 VmValue::String(Rc::from(s.chars().rev().collect::<String>()))
1689 }
1690 _ => v.clone(),
1691 })
1692 }
1693 "join" => {
1694 need(1, args)?;
1695 let sep = str_of(&args[0]);
1696 let parts: Vec<String> = match v {
1697 VmValue::List(items) => items.iter().map(str_of).collect(),
1698 VmValue::Set(items) => items.iter().map(str_of).collect(),
1699 VmValue::String(s) => return Ok(VmValue::String(s.clone())),
1700 _ => {
1701 return Err(TemplateError::new(
1702 line,
1703 col,
1704 format!("`join` requires a list (got {})", v.type_name()),
1705 ))
1706 }
1707 };
1708 Ok(VmValue::String(Rc::from(parts.join(&sep))))
1709 }
1710 "default" => {
1711 need(1, args)?;
1712 if truthy(v) {
1713 Ok(v.clone())
1714 } else {
1715 Ok(args[0].clone())
1716 }
1717 }
1718 "json" => {
1719 if args.len() > 1 {
1720 return Err(bad_arity());
1721 }
1722 let pretty = args.first().map(truthy).unwrap_or(false);
1723 let jv = crate::llm::helpers::vm_value_to_json(v);
1724 let s = if pretty {
1725 serde_json::to_string_pretty(&jv)
1726 } else {
1727 serde_json::to_string(&jv)
1728 }
1729 .map_err(|e| TemplateError::new(line, col, format!("json serialization: {e}")))?;
1730 Ok(VmValue::String(Rc::from(s)))
1731 }
1732 "indent" => {
1733 if args.is_empty() || args.len() > 2 {
1734 return Err(bad_arity());
1735 }
1736 let n = match &args[0] {
1737 VmValue::Int(n) => (*n).max(0) as usize,
1738 _ => {
1739 return Err(TemplateError::new(
1740 line,
1741 col,
1742 "`indent` requires an integer width",
1743 ))
1744 }
1745 };
1746 let indent_first = args.get(1).map(truthy).unwrap_or(false);
1747 let pad: String = " ".repeat(n);
1748 let s = str_of(v);
1749 let mut out = String::with_capacity(s.len() + n * 4);
1750 for (i, line) in s.split('\n').enumerate() {
1751 if i > 0 {
1752 out.push('\n');
1753 }
1754 if !line.is_empty() && (i > 0 || indent_first) {
1755 out.push_str(&pad);
1756 }
1757 out.push_str(line);
1758 }
1759 Ok(VmValue::String(Rc::from(out)))
1760 }
1761 "lines" => {
1762 need(0, args)?;
1763 let s = str_of(v);
1764 let list: Vec<VmValue> = s
1765 .split('\n')
1766 .map(|p| VmValue::String(Rc::from(p)))
1767 .collect();
1768 Ok(VmValue::List(Rc::new(list)))
1769 }
1770 "escape_md" => {
1771 need(0, args)?;
1772 let s = str_of(v);
1773 let mut out = String::with_capacity(s.len() + 8);
1774 for c in s.chars() {
1775 match c {
1776 '\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+'
1777 | '-' | '.' | '!' | '|' | '<' | '>' => {
1778 out.push('\\');
1779 out.push(c);
1780 }
1781 _ => out.push(c),
1782 }
1783 }
1784 Ok(VmValue::String(Rc::from(out)))
1785 }
1786 "replace" => {
1787 need(2, args)?;
1788 let s = str_of(v);
1789 let from = str_of(&args[0]);
1790 let to = str_of(&args[1]);
1791 Ok(VmValue::String(Rc::from(s.replace(&from, &to))))
1792 }
1793 other => Err(TemplateError::new(
1794 line,
1795 col,
1796 format!("unknown filter `{other}`"),
1797 )),
1798 }
1799}
1800
1801fn split_top_level(s: &str, delim: char) -> Vec<&str> {
1806 let mut out = Vec::new();
1807 let mut depth = 0i32;
1808 let mut in_str = false;
1809 let mut quote = '"';
1810 let bytes = s.as_bytes();
1811 let mut start = 0;
1812 let mut i = 0;
1813 while i < bytes.len() {
1814 let b = bytes[i] as char;
1815 if in_str {
1816 if b == '\\' {
1817 i += 2;
1818 continue;
1819 }
1820 if b == quote {
1821 in_str = false;
1822 }
1823 i += 1;
1824 continue;
1825 }
1826 match b {
1827 '"' | '\'' => {
1828 in_str = true;
1829 quote = b;
1830 }
1831 '(' | '[' | '{' => depth += 1,
1832 ')' | ']' | '}' => depth -= 1,
1833 c if c == delim && depth == 0 => {
1834 out.push(&s[start..i]);
1835 start = i + 1;
1836 }
1837 _ => {}
1838 }
1839 i += 1;
1840 }
1841 out.push(&s[start..]);
1842 out
1843}
1844
1845fn split_once_top_level(s: &str, delim: char) -> Option<(&str, &str)> {
1846 let mut depth = 0i32;
1847 let mut in_str = false;
1848 let mut quote = '"';
1849 let bytes = s.as_bytes();
1850 let mut i = 0;
1851 while i < bytes.len() {
1852 let b = bytes[i] as char;
1853 if in_str {
1854 if b == '\\' {
1855 i += 2;
1856 continue;
1857 }
1858 if b == quote {
1859 in_str = false;
1860 }
1861 i += 1;
1862 continue;
1863 }
1864 match b {
1865 '"' | '\'' => {
1866 in_str = true;
1867 quote = b;
1868 }
1869 '(' | '[' | '{' => depth += 1,
1870 ')' | ']' | '}' => depth -= 1,
1871 c if c == delim && depth == 0 => {
1872 return Some((&s[..i], &s[i + 1..]));
1873 }
1874 _ => {}
1875 }
1876 i += 1;
1877 }
1878 None
1879}
1880
1881fn split_once_keyword<'a>(s: &'a str, kw: &str) -> Option<(&'a str, &'a str)> {
1882 let mut depth = 0i32;
1884 let mut in_str = false;
1885 let mut quote = '"';
1886 let bytes = s.as_bytes();
1887 let kw_bytes = kw.as_bytes();
1888 let mut i = 0;
1889 while i + kw_bytes.len() <= bytes.len() {
1890 let b = bytes[i] as char;
1891 if in_str {
1892 if b == '\\' {
1893 i += 2;
1894 continue;
1895 }
1896 if b == quote {
1897 in_str = false;
1898 }
1899 i += 1;
1900 continue;
1901 }
1902 match b {
1903 '"' | '\'' => {
1904 in_str = true;
1905 quote = b;
1906 i += 1;
1907 continue;
1908 }
1909 '(' | '[' | '{' => {
1910 depth += 1;
1911 i += 1;
1912 continue;
1913 }
1914 ')' | ']' | '}' => {
1915 depth -= 1;
1916 i += 1;
1917 continue;
1918 }
1919 _ => {}
1920 }
1921 if depth == 0 && &bytes[i..i + kw_bytes.len()] == kw_bytes {
1922 return Some((&s[..i], &s[i + kw_bytes.len()..]));
1923 }
1924 i += 1;
1925 }
1926 None
1927}
1928
1929#[cfg(test)]
1934mod tests {
1935 use super::*;
1936
1937 fn dict(pairs: &[(&str, VmValue)]) -> BTreeMap<String, VmValue> {
1938 pairs
1939 .iter()
1940 .map(|(k, v)| (k.to_string(), v.clone()))
1941 .collect()
1942 }
1943
1944 fn s(v: &str) -> VmValue {
1945 VmValue::String(Rc::from(v))
1946 }
1947
1948 fn render(tpl: &str, b: &BTreeMap<String, VmValue>) -> String {
1949 render_template_result(tpl, Some(b), None, None).unwrap()
1950 }
1951
1952 #[test]
1953 fn bare_interp() {
1954 let b = dict(&[("name", s("Alice"))]);
1955 assert_eq!(render("hi {{name}}!", &b), "hi Alice!");
1956 }
1957
1958 #[test]
1959 fn bare_interp_missing_passthrough() {
1960 let b = dict(&[]);
1961 assert_eq!(render("hi {{name}}!", &b), "hi {{name}}!");
1962 }
1963
1964 #[test]
1965 fn legacy_if_truthy() {
1966 let b = dict(&[("x", VmValue::Bool(true))]);
1967 assert_eq!(render("{{if x}}yes{{end}}", &b), "yes");
1968 }
1969
1970 #[test]
1971 fn legacy_if_falsey() {
1972 let b = dict(&[("x", VmValue::Bool(false))]);
1973 assert_eq!(render("{{if x}}yes{{end}}", &b), "");
1974 }
1975
1976 #[test]
1977 fn if_else() {
1978 let b = dict(&[("x", VmValue::Bool(false))]);
1979 assert_eq!(render("{{if x}}A{{else}}B{{end}}", &b), "B");
1980 }
1981
1982 #[test]
1983 fn if_elif_else() {
1984 let b = dict(&[("n", VmValue::Int(2))]);
1985 let tpl = "{{if n == 1}}one{{elif n == 2}}two{{elif n == 3}}three{{else}}many{{end}}";
1986 assert_eq!(render(tpl, &b), "two");
1987 }
1988
1989 #[test]
1990 fn for_loop_basic() {
1991 let items = VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")]));
1992 let b = dict(&[("xs", items)]);
1993 assert_eq!(render("{{for x in xs}}{{x}},{{end}}", &b), "a,b,c,");
1994 }
1995
1996 #[test]
1997 fn for_loop_vars() {
1998 let items = VmValue::List(Rc::new(vec![s("a"), s("b")]));
1999 let b = dict(&[("xs", items)]);
2000 let tpl = "{{for x in xs}}{{loop.index}}:{{x}}{{if !loop.last}},{{end}}{{end}}";
2001 assert_eq!(render(tpl, &b), "1:a,2:b");
2002 }
2003
2004 #[test]
2005 fn for_empty_else() {
2006 let b = dict(&[("xs", VmValue::List(Rc::new(vec![])))]);
2007 assert_eq!(render("{{for x in xs}}A{{else}}empty{{end}}", &b), "empty");
2008 }
2009
2010 #[test]
2011 fn for_dict_kv() {
2012 let mut d: BTreeMap<String, VmValue> = BTreeMap::new();
2013 d.insert("a".into(), VmValue::Int(1));
2014 d.insert("b".into(), VmValue::Int(2));
2015 let b = dict(&[("m", VmValue::Dict(Rc::new(d)))]);
2016 assert_eq!(
2017 render("{{for k, v in m}}{{k}}={{v}};{{end}}", &b),
2018 "a=1;b=2;"
2019 );
2020 }
2021
2022 #[test]
2023 fn nested_path() {
2024 let mut inner: BTreeMap<String, VmValue> = BTreeMap::new();
2025 inner.insert("name".into(), s("Alice"));
2026 let b = dict(&[("user", VmValue::Dict(Rc::new(inner)))]);
2027 assert_eq!(render("{{user.name}}", &b), "Alice");
2028 }
2029
2030 #[test]
2031 fn list_index() {
2032 let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")])))]);
2033 assert_eq!(render("{{xs[1]}}", &b), "b");
2034 }
2035
2036 #[test]
2037 fn filter_upper() {
2038 let b = dict(&[("n", s("alice"))]);
2039 assert_eq!(render("{{n | upper}}", &b), "ALICE");
2040 }
2041
2042 #[test]
2043 fn filter_default() {
2044 let b = dict(&[("n", s(""))]);
2045 assert_eq!(render("{{n | default: \"anon\"}}", &b), "anon");
2046 }
2047
2048 #[test]
2049 fn filter_join() {
2050 let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b")])))]);
2051 assert_eq!(render("{{xs | join: \", \"}}", &b), "a, b");
2052 }
2053
2054 #[test]
2055 fn comparison_ops() {
2056 let b = dict(&[("n", VmValue::Int(5))]);
2057 assert_eq!(render("{{if n > 3}}big{{end}}", &b), "big");
2058 assert_eq!(render("{{if n >= 5 and n < 10}}ok{{end}}", &b), "ok");
2059 }
2060
2061 #[test]
2062 fn bool_not() {
2063 let b = dict(&[("x", VmValue::Bool(false))]);
2064 assert_eq!(render("{{if not x}}yes{{end}}", &b), "yes");
2065 assert_eq!(render("{{if !x}}yes{{end}}", &b), "yes");
2066 }
2067
2068 #[test]
2069 fn raw_block() {
2070 let b = dict(&[]);
2071 assert_eq!(
2072 render("A {{ raw }}{{not-a-directive}}{{ endraw }} B", &b),
2073 "A {{not-a-directive}} B"
2074 );
2075 }
2076
2077 #[test]
2078 fn comment_stripped() {
2079 let b = dict(&[("x", s("hi"))]);
2080 assert_eq!(render("A{{# hidden #}}B{{x}}", &b), "ABhi");
2081 }
2082
2083 #[test]
2084 fn whitespace_trim() {
2085 let b = dict(&[("x", s("v"))]);
2086 let tpl = "line1\n {{- x -}} \nline2";
2088 assert_eq!(render(tpl, &b), "line1vline2");
2089 }
2090
2091 #[test]
2092 fn filter_json() {
2093 let b = dict(&[(
2094 "x",
2095 VmValue::Dict(Rc::new({
2096 let mut m = BTreeMap::new();
2097 m.insert("a".into(), VmValue::Int(1));
2098 m
2099 })),
2100 )]);
2101 assert_eq!(render("{{x | json}}", &b), r#"{"a":1}"#);
2102 }
2103
2104 #[test]
2105 fn error_unterminated_if() {
2106 let b = dict(&[("x", VmValue::Bool(true))]);
2107 let r = render_template_result("{{if x}}open", Some(&b), None, None);
2108 assert!(r.is_err());
2109 }
2110
2111 #[test]
2112 fn error_unknown_filter() {
2113 let b = dict(&[("x", s("a"))]);
2114 let r = render_template_result("{{x | bogus}}", Some(&b), None, None);
2115 assert!(r.is_err());
2116 }
2117
2118 #[test]
2119 fn include_with() {
2120 use std::fs;
2121 let dir = tempdir();
2122 let partial = dir.join("p.prompt");
2123 fs::write(&partial, "[{{name}}]").unwrap();
2124 let parent = dir.join("main.prompt");
2125 fs::write(
2126 &parent,
2127 r#"hello {{ include "p.prompt" with { name: who } }}!"#,
2128 )
2129 .unwrap();
2130 let b = dict(&[("who", s("world"))]);
2131 let src = fs::read_to_string(&parent).unwrap();
2132 let out = render_template_result(&src, Some(&b), Some(&dir), Some(&parent)).unwrap();
2133 assert_eq!(out, "hello [world]!");
2134 }
2135
2136 #[test]
2137 fn include_cycle_detected() {
2138 use std::fs;
2139 let dir = tempdir();
2140 let a = dir.join("a.prompt");
2141 let b = dir.join("b.prompt");
2142 fs::write(&a, r#"A{{ include "b.prompt" }}"#).unwrap();
2143 fs::write(&b, r#"B{{ include "a.prompt" }}"#).unwrap();
2144 let src = fs::read_to_string(&a).unwrap();
2145 let r = render_template_result(&src, None, Some(&dir), Some(&a));
2146 assert!(r.is_err());
2147 assert!(r.unwrap_err().kind.contains("circular include"));
2148 }
2149
2150 fn tempdir() -> PathBuf {
2151 let base = std::env::temp_dir().join(format!("harn-tpl-{}", nanoid()));
2152 std::fs::create_dir_all(&base).unwrap();
2153 base
2154 }
2155
2156 fn nanoid() -> String {
2157 use std::time::{SystemTime, UNIX_EPOCH};
2158 format!(
2159 "{}",
2160 SystemTime::now()
2161 .duration_since(UNIX_EPOCH)
2162 .unwrap()
2163 .as_nanos()
2164 )
2165 }
2166}