1use std::cell::RefCell;
25use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27use std::rc::Rc;
28
29use crate::value::{values_equal, VmError, VmValue};
30
31thread_local! {
37 static PROMPT_REGISTRY: RefCell<Vec<RegisteredPrompt>> = const { RefCell::new(Vec::new()) };
38 static PROMPT_RENDER_INDICES: RefCell<BTreeMap<String, Vec<u64>>> =
44 const { RefCell::new(BTreeMap::new()) };
45 static PROMPT_RENDER_ORDINAL: RefCell<u64> = const { RefCell::new(0) };
49}
50
51const PROMPT_REGISTRY_CAP: usize = 64;
52
53#[derive(Debug, Clone)]
54pub struct RegisteredPrompt {
55 pub prompt_id: String,
56 pub template_uri: String,
57 pub rendered: String,
58 pub spans: Vec<PromptSourceSpan>,
59}
60
61pub(crate) fn register_prompt(
66 template_uri: String,
67 rendered: String,
68 spans: Vec<PromptSourceSpan>,
69) -> String {
70 let prompt_id = format!("prompt-{}", next_prompt_serial());
71 PROMPT_REGISTRY.with(|reg| {
72 let mut reg = reg.borrow_mut();
73 if reg.len() >= PROMPT_REGISTRY_CAP {
74 reg.remove(0);
75 }
76 reg.push(RegisteredPrompt {
77 prompt_id: prompt_id.clone(),
78 template_uri,
79 rendered,
80 spans,
81 });
82 });
83 prompt_id
84}
85
86thread_local! {
87 static PROMPT_SERIAL: RefCell<u64> = const { RefCell::new(0) };
88}
89
90fn next_prompt_serial() -> u64 {
91 PROMPT_SERIAL.with(|s| {
92 let mut s = s.borrow_mut();
93 *s += 1;
94 *s
95 })
96}
97
98pub fn lookup_prompt_span(
104 prompt_id: &str,
105 output_offset: usize,
106) -> Option<(String, PromptSourceSpan)> {
107 PROMPT_REGISTRY.with(|reg| {
108 let reg = reg.borrow();
109 let entry = reg.iter().find(|p| p.prompt_id == prompt_id)?;
110 let best = entry
111 .spans
112 .iter()
113 .filter(|s| {
114 output_offset >= s.output_start
115 && output_offset < s.output_end.max(s.output_start + 1)
116 })
117 .min_by_key(|s| {
118 let width = s.output_end.saturating_sub(s.output_start);
119 let kind_weight = match s.kind {
120 PromptSpanKind::Expr => 0,
121 PromptSpanKind::LegacyBareInterp => 1,
122 PromptSpanKind::Text => 2,
123 PromptSpanKind::Include => 3,
124 PromptSpanKind::ForIteration => 4,
125 PromptSpanKind::If => 5,
126 };
127 (kind_weight, width)
128 })?
129 .clone();
130 Some((entry.template_uri.clone(), best))
131 })
132}
133
134pub fn lookup_prompt_consumers(
138 template_uri: &str,
139 template_line_start: usize,
140 template_line_end: usize,
141) -> Vec<(String, PromptSourceSpan)> {
142 PROMPT_REGISTRY.with(|reg| {
143 let reg = reg.borrow();
144 reg.iter()
145 .filter(|p| p.template_uri == template_uri)
146 .flat_map(|p| {
147 let prompt_id = p.prompt_id.clone();
148 p.spans
149 .iter()
150 .filter(move |s| {
151 let line = s.template_line;
152 line > 0 && line >= template_line_start && line <= template_line_end
153 })
154 .cloned()
155 .map(move |s| (prompt_id.clone(), s))
156 })
157 .collect()
158 })
159}
160
161pub fn record_prompt_render_index(prompt_id: &str, event_index: u64) {
166 PROMPT_RENDER_INDICES.with(|map| {
167 map.borrow_mut()
168 .entry(prompt_id.to_string())
169 .or_default()
170 .push(event_index);
171 });
172}
173
174pub fn next_prompt_render_ordinal() -> u64 {
180 PROMPT_RENDER_ORDINAL.with(|c| {
181 let mut n = c.borrow_mut();
182 *n += 1;
183 *n
184 })
185}
186
187pub fn prompt_render_indices(prompt_id: &str) -> Vec<u64> {
191 PROMPT_RENDER_INDICES.with(|map| map.borrow().get(prompt_id).cloned().unwrap_or_default())
192}
193
194pub(crate) fn reset_prompt_registry() {
197 PROMPT_REGISTRY.with(|reg| reg.borrow_mut().clear());
198 PROMPT_SERIAL.with(|s| *s.borrow_mut() = 0);
199 PROMPT_RENDER_INDICES.with(|map| map.borrow_mut().clear());
200 PROMPT_RENDER_ORDINAL.with(|c| *c.borrow_mut() = 0);
201}
202
203pub fn validate_template_syntax(src: &str) -> Result<(), String> {
208 parse(src).map(|_| ()).map_err(|e| e.message())
209}
210
211pub(crate) fn render_template_result(
215 template: &str,
216 bindings: Option<&BTreeMap<String, VmValue>>,
217 base: Option<&Path>,
218 source_path: Option<&Path>,
219) -> Result<String, TemplateError> {
220 let (rendered, _spans) =
221 render_template_with_provenance(template, bindings, base, source_path, false)?;
222 Ok(rendered)
223}
224
225#[derive(Debug, Clone)]
237pub struct PromptSourceSpan {
238 pub template_line: usize,
239 pub template_col: usize,
240 pub output_start: usize,
241 pub output_end: usize,
242 pub kind: PromptSpanKind,
243 pub bound_value: Option<String>,
244 pub parent_span: Option<Box<PromptSourceSpan>>,
250 pub template_uri: String,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum PromptSpanKind {
261 Text,
263 Expr,
266 LegacyBareInterp,
269 If,
271 ForIteration,
273 Include,
276}
277
278pub(crate) fn render_template_with_provenance(
284 template: &str,
285 bindings: Option<&BTreeMap<String, VmValue>>,
286 base: Option<&Path>,
287 source_path: Option<&Path>,
288 collect_provenance: bool,
289) -> Result<(String, Vec<PromptSourceSpan>), TemplateError> {
290 let nodes = parse(template).map_err(|mut e| {
291 if let Some(p) = source_path {
292 e.path = Some(p.to_path_buf());
293 }
294 e
295 })?;
296 let mut out = String::with_capacity(template.len());
297 let mut scope = Scope::new(bindings);
298 let mut rc = RenderCtx {
299 base: base.map(Path::to_path_buf),
300 include_stack: Vec::new(),
301 current_path: source_path.map(Path::to_path_buf),
302 current_include_parent: None,
303 };
304 let mut spans = if collect_provenance {
305 Some(Vec::new())
306 } else {
307 None
308 };
309 render_nodes(&nodes, &mut scope, &mut rc, &mut out, spans.as_mut()).map_err(|mut e| {
310 if e.path.is_none() {
311 e.path = source_path.map(Path::to_path_buf);
312 }
313 e
314 })?;
315 Ok((out, spans.unwrap_or_default()))
316}
317
318#[derive(Debug, Clone)]
323pub(crate) struct TemplateError {
324 pub path: Option<PathBuf>,
325 pub line: usize,
326 pub col: usize,
327 pub kind: String,
328}
329
330impl TemplateError {
331 fn new(line: usize, col: usize, msg: impl Into<String>) -> Self {
332 Self {
333 path: None,
334 line,
335 col,
336 kind: msg.into(),
337 }
338 }
339
340 pub(crate) fn message(&self) -> String {
341 let p = self
342 .path
343 .as_ref()
344 .map(|p| format!("{} ", p.display()))
345 .unwrap_or_default();
346 format!("{}at {}:{}: {}", p, self.line, self.col, self.kind)
347 }
348}
349
350impl From<TemplateError> for VmError {
351 fn from(e: TemplateError) -> Self {
352 VmError::Thrown(VmValue::String(Rc::from(e.message())))
353 }
354}
355
356#[derive(Debug, Clone)]
361enum Token {
362 Text {
364 content: String,
365 trim_right: bool,
367 trim_left: bool,
369 },
370 Directive {
372 body: String,
373 line: usize,
374 col: usize,
375 },
376 Raw(String),
378}
379
380fn tokenize(src: &str) -> Result<Vec<Token>, TemplateError> {
381 let bytes = src.as_bytes();
382 let mut tokens: Vec<Token> = Vec::new();
383 let mut cursor = 0;
384 let mut pending_trim_left = false;
385 let len = bytes.len();
386
387 while cursor < len {
388 let open = find_from(src, cursor, "{{");
390 let text_end = open.unwrap_or(len);
391 let raw_text = &src[cursor..text_end];
392
393 let this_trim_left = pending_trim_left;
394 pending_trim_left = false;
395
396 let mut this_trim_right = false;
397 if let Some(o) = open {
398 if o + 2 < len && bytes[o + 2] == b'-' {
400 this_trim_right = true;
401 }
402 }
403
404 if !raw_text.is_empty() || this_trim_left || this_trim_right {
405 tokens.push(Token::Text {
406 content: raw_text.to_string(),
407 trim_right: this_trim_right,
408 trim_left: this_trim_left,
409 });
410 }
411
412 let Some(open) = open else {
413 break;
414 };
415
416 let body_start = open + 2 + if this_trim_right { 1 } else { 0 };
418
419 if body_start < len && bytes[body_start] == b'#' {
421 let after_hash = body_start + 1;
423 let Some(close_hash) = find_from(src, after_hash, "#}}") else {
424 let (line, col) = line_col(src, open);
425 return Err(TemplateError::new(line, col, "unterminated comment"));
426 };
427 cursor = close_hash + 3;
428 continue;
432 }
433
434 let body_trim_start = skip_ws(src, body_start);
436 let raw_kw_end = body_trim_start + 3;
437 if raw_kw_end <= len && &src[body_trim_start..raw_kw_end.min(len)] == "raw" && {
438 let after = raw_kw_end;
440 after >= len
441 || bytes[after] == b' '
442 || bytes[after] == b'\t'
443 || bytes[after] == b'\n'
444 || bytes[after] == b'\r'
445 || (after + 1 < len && &src[after..after + 2] == "}}")
446 || (after + 2 < len && &src[after..after + 3] == "-}}")
447 } {
448 let Some(dir_close) = find_from(src, raw_kw_end, "}}") else {
450 let (line, col) = line_col(src, open);
451 return Err(TemplateError::new(line, col, "unterminated directive"));
452 };
453 let raw_body_start = dir_close + 2;
455 let trim_after_open = dir_close > 0 && bytes[dir_close - 1] == b'-';
456 let _ = trim_after_open; let (raw_end_open, raw_end_close) =
460 find_endraw(src, raw_body_start).ok_or_else(|| {
461 let (line, col) = line_col(src, open);
462 TemplateError::new(line, col, "unterminated `{{ raw }}` block")
463 })?;
464 let raw_content = src[raw_body_start..raw_end_open].to_string();
465 tokens.push(Token::Raw(raw_content));
466 cursor = raw_end_close;
467 continue;
468 }
469
470 let (close_pos, trim_after) = find_directive_close(src, body_start).ok_or_else(|| {
473 let (line, col) = line_col(src, open);
474 TemplateError::new(line, col, "unterminated directive")
475 })?;
476 let body_end = if trim_after { close_pos - 1 } else { close_pos };
477 let body = src[body_start..body_end].trim().to_string();
478 let (line, col) = line_col(src, open);
479 tokens.push(Token::Directive { body, line, col });
480 cursor = close_pos + 2;
481 pending_trim_left = trim_after;
482 }
483
484 Ok(tokens)
485}
486
487fn find_from(s: &str, from: usize, pat: &str) -> Option<usize> {
488 s[from..].find(pat).map(|i| i + from)
489}
490
491fn skip_ws(s: &str, from: usize) -> usize {
492 let bytes = s.as_bytes();
493 let mut i = from;
494 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
495 i += 1;
496 }
497 i
498}
499
500fn line_col(s: &str, offset: usize) -> (usize, usize) {
501 let mut line = 1usize;
502 let mut col = 1usize;
503 for (i, ch) in s.char_indices() {
504 if i >= offset {
505 break;
506 }
507 if ch == '\n' {
508 line += 1;
509 col = 1;
510 } else {
511 col += 1;
512 }
513 }
514 (line, col)
515}
516
517fn find_directive_close(s: &str, start: usize) -> Option<(usize, bool)> {
521 let bytes = s.as_bytes();
522 let mut i = start;
523 let mut in_str = false;
524 let mut str_quote = b'"';
525 while i + 1 < bytes.len() {
526 let b = bytes[i];
527 if in_str {
528 if b == b'\\' {
529 i += 2;
530 continue;
531 }
532 if b == str_quote {
533 in_str = false;
534 }
535 i += 1;
536 continue;
537 }
538 if b == b'"' || b == b'\'' {
539 in_str = true;
540 str_quote = b;
541 i += 1;
542 continue;
543 }
544 if b == b'}' && bytes[i + 1] == b'}' {
545 let trim = i > start && bytes[i - 1] == b'-';
546 return Some((i, trim));
547 }
548 i += 1;
549 }
550 None
551}
552
553fn find_endraw(s: &str, from: usize) -> Option<(usize, usize)> {
556 let mut cursor = from;
557 while let Some(open) = find_from(s, cursor, "{{") {
558 let after = open + 2;
559 let body_start = if s.as_bytes().get(after) == Some(&b'-') {
560 after + 1
561 } else {
562 after
563 };
564 let body_trim_start = skip_ws(s, body_start);
565 let close = find_directive_close(s, body_start)?;
566 let body_end = if close.1 { close.0 - 1 } else { close.0 };
567 let body = s[body_trim_start..body_end].trim();
568 if body == "endraw" {
569 return Some((open, close.0 + 2));
570 }
571 cursor = close.0 + 2;
572 }
573 None
574}
575
576#[derive(Debug, Clone)]
581enum Node {
582 Text(String),
583 Expr {
584 expr: Expr,
585 line: usize,
586 col: usize,
587 },
588 If {
589 branches: Vec<(Expr, Vec<Node>)>,
590 else_branch: Option<Vec<Node>>,
591 line: usize,
592 col: usize,
593 },
594 For {
595 value_var: String,
596 key_var: Option<String>,
597 iter: Expr,
598 body: Vec<Node>,
599 empty: Option<Vec<Node>>,
600 line: usize,
601 col: usize,
602 },
603 Include {
604 path: Expr,
605 with: Option<Vec<(String, Expr)>>,
606 line: usize,
607 col: usize,
608 },
609 LegacyBareInterp {
612 ident: String,
613 },
614}
615
616#[derive(Debug, Clone)]
617enum Expr {
618 Nil,
619 Bool(bool),
620 Int(i64),
621 Float(f64),
622 Str(String),
623 Path(Vec<PathSeg>),
624 Unary(UnOp, Box<Expr>),
625 Binary(BinOp, Box<Expr>, Box<Expr>),
626 Filter(Box<Expr>, String, Vec<Expr>),
627}
628
629#[derive(Debug, Clone)]
630enum PathSeg {
631 Field(String),
632 Index(i64),
633 Key(String),
634}
635
636#[derive(Debug, Clone, Copy)]
637enum UnOp {
638 Not,
639}
640
641#[derive(Debug, Clone, Copy, PartialEq)]
642enum BinOp {
643 Eq,
644 Neq,
645 Lt,
646 Le,
647 Gt,
648 Ge,
649 And,
650 Or,
651}
652
653fn parse(src: &str) -> Result<Vec<Node>, TemplateError> {
658 let tokens = tokenize(src)?;
659 let mut p = Parser {
660 tokens: &tokens,
661 pos: 0,
662 };
663 let nodes = p.parse_block(&[])?;
664 if p.pos < tokens.len() {
665 }
667 Ok(nodes)
668}
669
670struct Parser<'a> {
671 tokens: &'a [Token],
672 pos: usize,
673}
674
675impl<'a> Parser<'a> {
676 fn peek(&self) -> Option<&'a Token> {
677 self.tokens.get(self.pos)
678 }
679
680 fn parse_block(&mut self, stops: &[&str]) -> Result<Vec<Node>, TemplateError> {
681 let mut out = Vec::new();
682 while let Some(tok) = self.peek() {
683 match tok {
684 Token::Text {
685 content,
686 trim_right,
687 trim_left,
688 } => {
689 let mut s = content.clone();
690 if *trim_left {
691 s = trim_leading_line(&s);
692 }
693 if *trim_right {
694 s = trim_trailing_line(&s);
695 }
696 if !s.is_empty() {
697 out.push(Node::Text(s));
698 }
699 self.pos += 1;
700 }
701 Token::Raw(content) => {
702 if !content.is_empty() {
703 out.push(Node::Text(content.clone()));
704 }
705 self.pos += 1;
706 }
707 Token::Directive { body, line, col } => {
708 let (line, col) = (*line, *col);
709 let body = body.clone();
710 let first_word = first_word(&body);
712 if stops.contains(&first_word) {
713 return Ok(out);
714 }
715 self.pos += 1;
716
717 if body == "end" {
718 return Err(TemplateError::new(line, col, "unexpected `{{ end }}`"));
719 }
720 if body == "else" {
721 return Err(TemplateError::new(line, col, "unexpected `{{ else }}`"));
722 }
723 if first_word == "elif" {
724 return Err(TemplateError::new(line, col, "unexpected `{{ elif }}`"));
725 }
726
727 if first_word == "if" {
728 let cond_src = body[2..].trim();
729 let cond = parse_expr(cond_src, line, col)?;
730 let node = self.parse_if(cond, line, col)?;
731 out.push(node);
732 } else if first_word == "for" {
733 let node = self.parse_for(body[3..].trim(), line, col)?;
734 out.push(node);
735 } else if first_word == "include" {
736 let node = parse_include(body[7..].trim(), line, col)?;
737 out.push(node);
738 } else if is_bare_ident(&body) {
739 out.push(Node::LegacyBareInterp { ident: body });
740 } else {
741 let expr = parse_expr(&body, line, col)?;
742 out.push(Node::Expr { expr, line, col });
743 }
744 }
745 }
746 }
747 Ok(out)
748 }
749
750 fn parse_if(
751 &mut self,
752 first_cond: Expr,
753 line: usize,
754 col: usize,
755 ) -> Result<Node, TemplateError> {
756 let mut branches = Vec::new();
757 let mut else_branch = None;
758 let mut cur_cond = first_cond;
759 loop {
760 let body = self.parse_block(&["end", "else", "elif"])?;
761 branches.push((cur_cond, body));
762 let tok = self.peek().cloned();
764 match tok {
765 Some(Token::Directive {
766 body: tbody,
767 line: tline,
768 col: tcol,
769 }) => {
770 let fw = first_word(&tbody);
771 self.pos += 1;
772 match fw {
773 "end" => break,
774 "else" => {
775 let eb = self.parse_block(&["end"])?;
776 else_branch = Some(eb);
777 match self.peek() {
779 Some(Token::Directive { body, .. }) if body == "end" => {
780 self.pos += 1;
781 }
782 _ => {
783 return Err(TemplateError::new(
784 tline,
785 tcol,
786 "`{{ else }}` missing matching `{{ end }}`",
787 ));
788 }
789 }
790 break;
791 }
792 "elif" => {
793 let cond = parse_expr(tbody[4..].trim(), tline, tcol)?;
794 cur_cond = cond;
795 continue;
796 }
797 _ => unreachable!(),
798 }
799 }
800 _ => {
801 return Err(TemplateError::new(
802 line,
803 col,
804 "`{{ if }}` missing matching `{{ end }}`",
805 ));
806 }
807 }
808 }
809 Ok(Node::If {
810 branches,
811 else_branch,
812 line,
813 col,
814 })
815 }
816
817 fn parse_for(&mut self, spec: &str, line: usize, col: usize) -> Result<Node, TemplateError> {
818 let (head, iter_src) = match split_once_keyword(spec, " in ") {
820 Some(p) => p,
821 None => return Err(TemplateError::new(line, col, "expected `in` in for-loop")),
822 };
823 let head = head.trim();
824 let iter_src = iter_src.trim();
825 let (value_var, key_var) = if let Some((a, b)) = head.split_once(',') {
826 let a = a.trim().to_string();
827 let b = b.trim().to_string();
828 if !is_ident(&a) || !is_ident(&b) {
829 return Err(TemplateError::new(line, col, "invalid for-loop variables"));
830 }
831 (b, Some(a)) } else {
833 if !is_ident(head) {
834 return Err(TemplateError::new(line, col, "invalid for-loop variable"));
835 }
836 (head.to_string(), None)
837 };
838 let iter = parse_expr(iter_src, line, col)?;
839 let body = self.parse_block(&["end", "else"])?;
840 let (empty, _) = match self.peek().cloned() {
841 Some(Token::Directive { body: tbody, .. }) => {
842 let fw = first_word(&tbody);
843 self.pos += 1;
844 if fw == "end" {
845 (None, ())
846 } else if fw == "else" {
847 let empty_body = self.parse_block(&["end"])?;
848 match self.peek() {
849 Some(Token::Directive { body, .. }) if body == "end" => {
850 self.pos += 1;
851 }
852 _ => {
853 return Err(TemplateError::new(
854 line,
855 col,
856 "`{{ else }}` missing matching `{{ end }}`",
857 ));
858 }
859 }
860 (Some(empty_body), ())
861 } else {
862 unreachable!()
863 }
864 }
865 _ => {
866 return Err(TemplateError::new(
867 line,
868 col,
869 "`{{ for }}` missing matching `{{ end }}`",
870 ));
871 }
872 };
873 Ok(Node::For {
874 value_var,
875 key_var,
876 iter,
877 body,
878 empty,
879 line,
880 col,
881 })
882 }
883}
884
885fn parse_include(spec: &str, line: usize, col: usize) -> Result<Node, TemplateError> {
886 let (path_src, with_src) = match split_once_keyword(spec, " with ") {
888 Some((a, b)) => (a.trim(), Some(b.trim())),
889 None => (spec.trim(), None),
890 };
891 let path = parse_expr(path_src, line, col)?;
892 let with = if let Some(src) = with_src {
893 Some(parse_dict_literal(src, line, col)?)
894 } else {
895 None
896 };
897 Ok(Node::Include {
898 path,
899 with,
900 line,
901 col,
902 })
903}
904
905fn parse_dict_literal(
906 src: &str,
907 line: usize,
908 col: usize,
909) -> Result<Vec<(String, Expr)>, TemplateError> {
910 let s = src.trim();
911 if !s.starts_with('{') || !s.ends_with('}') {
912 return Err(TemplateError::new(
913 line,
914 col,
915 "expected `{ ... }` after `with`",
916 ));
917 }
918 let inner = &s[1..s.len() - 1];
919 let mut pairs = Vec::new();
920 for chunk in split_top_level(inner, ',') {
921 let chunk = chunk.trim();
922 if chunk.is_empty() {
923 continue;
924 }
925 let (k, v) = match split_once_top_level(chunk, ':') {
926 Some(p) => p,
927 None => {
928 return Err(TemplateError::new(
929 line,
930 col,
931 "expected `key: value` in include bindings",
932 ));
933 }
934 };
935 let k = k.trim();
936 if !is_ident(k) {
937 return Err(TemplateError::new(line, col, "invalid include binding key"));
938 }
939 let v = parse_expr(v.trim(), line, col)?;
940 pairs.push((k.to_string(), v));
941 }
942 Ok(pairs)
943}
944
945fn first_word(s: &str) -> &str {
946 s.split(|c: char| c.is_whitespace()).next().unwrap_or("")
947}
948
949fn is_ident(s: &str) -> bool {
950 let mut chars = s.chars();
951 match chars.next() {
952 Some(c) if c.is_alphabetic() || c == '_' => {}
953 _ => return false,
954 }
955 chars.all(|c| c.is_alphanumeric() || c == '_')
956}
957
958fn is_bare_ident(s: &str) -> bool {
959 is_ident(s)
962}
963
964fn trim_leading_line(s: &str) -> String {
965 let mut i = 0;
967 let bytes = s.as_bytes();
968 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
969 i += 1;
970 }
971 if i < bytes.len() && bytes[i] == b'\n' {
972 return s[i + 1..].to_string();
973 }
974 if i < bytes.len() && bytes[i] == b'\r' {
975 if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
976 return s[i + 2..].to_string();
977 }
978 return s[i + 1..].to_string();
979 }
980 s[i..].to_string()
982}
983
984fn trim_trailing_line(s: &str) -> String {
985 let bytes = s.as_bytes();
986 let mut i = bytes.len();
987 while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
988 i -= 1;
989 }
990 if i > 0 && bytes[i - 1] == b'\n' {
991 let end = i - 1;
993 let end = if end > 0 && bytes[end - 1] == b'\r' {
994 end - 1
995 } else {
996 end
997 };
998 return s[..end].to_string();
999 }
1000 s[..i].to_string()
1002}
1003
1004fn parse_expr(src: &str, line: usize, col: usize) -> Result<Expr, TemplateError> {
1007 let tokens = tokenize_expr(src, line, col)?;
1008 let mut p = ExprParser {
1009 toks: &tokens,
1010 pos: 0,
1011 line,
1012 col,
1013 };
1014 let e = p.parse_filter()?;
1015 if p.pos < tokens.len() {
1016 return Err(TemplateError::new(
1017 line,
1018 col,
1019 format!("unexpected token `{:?}` in expression", p.toks[p.pos]),
1020 ));
1021 }
1022 Ok(e)
1023}
1024
1025#[derive(Debug, Clone, PartialEq)]
1026enum EToken {
1027 Ident(String),
1028 Str(String),
1029 Int(i64),
1030 Float(f64),
1031 LParen,
1032 RParen,
1033 LBracket,
1034 RBracket,
1035 Dot,
1036 Comma,
1037 Colon,
1038 Pipe,
1039 Bang,
1040 EqEq,
1041 BangEq,
1042 Lt,
1043 Le,
1044 Gt,
1045 Ge,
1046 AndKw,
1047 OrKw,
1048 NotKw,
1049 True,
1050 False,
1051 Nil,
1052}
1053
1054fn tokenize_expr(src: &str, line: usize, col: usize) -> Result<Vec<EToken>, TemplateError> {
1055 let bytes = src.as_bytes();
1056 let mut toks = Vec::new();
1057 let mut i = 0;
1058 while i < bytes.len() {
1059 let b = bytes[i];
1060 if b.is_ascii_whitespace() {
1061 i += 1;
1062 continue;
1063 }
1064 match b {
1065 b'(' => {
1066 toks.push(EToken::LParen);
1067 i += 1;
1068 }
1069 b')' => {
1070 toks.push(EToken::RParen);
1071 i += 1;
1072 }
1073 b'[' => {
1074 toks.push(EToken::LBracket);
1075 i += 1;
1076 }
1077 b']' => {
1078 toks.push(EToken::RBracket);
1079 i += 1;
1080 }
1081 b'.' => {
1082 toks.push(EToken::Dot);
1083 i += 1;
1084 }
1085 b',' => {
1086 toks.push(EToken::Comma);
1087 i += 1;
1088 }
1089 b':' => {
1090 toks.push(EToken::Colon);
1091 i += 1;
1092 }
1093 b'|' => {
1094 if i + 1 < bytes.len() && bytes[i + 1] == b'|' {
1095 toks.push(EToken::OrKw);
1096 i += 2;
1097 } else {
1098 toks.push(EToken::Pipe);
1099 i += 1;
1100 }
1101 }
1102 b'&' => {
1103 if i + 1 < bytes.len() && bytes[i + 1] == b'&' {
1104 toks.push(EToken::AndKw);
1105 i += 2;
1106 } else {
1107 return Err(TemplateError::new(line, col, "unexpected `&`"));
1108 }
1109 }
1110 b'!' => {
1111 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
1112 toks.push(EToken::BangEq);
1113 i += 2;
1114 } else {
1115 toks.push(EToken::Bang);
1116 i += 1;
1117 }
1118 }
1119 b'=' => {
1120 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
1121 toks.push(EToken::EqEq);
1122 i += 2;
1123 } else {
1124 return Err(TemplateError::new(line, col, "unexpected `=` (use `==`)"));
1125 }
1126 }
1127 b'<' => {
1128 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
1129 toks.push(EToken::Le);
1130 i += 2;
1131 } else {
1132 toks.push(EToken::Lt);
1133 i += 1;
1134 }
1135 }
1136 b'>' => {
1137 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
1138 toks.push(EToken::Ge);
1139 i += 2;
1140 } else {
1141 toks.push(EToken::Gt);
1142 i += 1;
1143 }
1144 }
1145 b'"' | b'\'' => {
1146 let quote = b;
1147 let start = i + 1;
1148 let mut j = start;
1149 let mut out = String::new();
1150 while j < bytes.len() && bytes[j] != quote {
1151 if bytes[j] == b'\\' && j + 1 < bytes.len() {
1152 match bytes[j + 1] {
1153 b'n' => out.push('\n'),
1154 b't' => out.push('\t'),
1155 b'r' => out.push('\r'),
1156 b'\\' => out.push('\\'),
1157 b'"' => out.push('"'),
1158 b'\'' => out.push('\''),
1159 c => out.push(c as char),
1160 }
1161 j += 2;
1162 continue;
1163 }
1164 out.push(bytes[j] as char);
1165 j += 1;
1166 }
1167 if j >= bytes.len() {
1168 return Err(TemplateError::new(line, col, "unterminated string literal"));
1169 }
1170 toks.push(EToken::Str(out));
1171 i = j + 1;
1172 }
1173 b'0'..=b'9' | b'-'
1174 if b != b'-' || (i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) =>
1175 {
1176 let start = i;
1177 if bytes[i] == b'-' {
1178 i += 1;
1179 }
1180 let mut is_float = false;
1181 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
1182 if bytes[i] == b'.' {
1183 if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
1185 is_float = true;
1186 i += 1;
1187 continue;
1188 } else {
1189 break;
1190 }
1191 }
1192 i += 1;
1193 }
1194 let lex = &src[start..i];
1195 if is_float {
1196 let v: f64 = lex.parse().map_err(|_| {
1197 TemplateError::new(line, col, format!("invalid number `{lex}`"))
1198 })?;
1199 toks.push(EToken::Float(v));
1200 } else {
1201 let v: i64 = lex.parse().map_err(|_| {
1202 TemplateError::new(line, col, format!("invalid integer `{lex}`"))
1203 })?;
1204 toks.push(EToken::Int(v));
1205 }
1206 }
1207 c if c.is_ascii_alphabetic() || c == b'_' => {
1208 let start = i;
1209 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
1210 i += 1;
1211 }
1212 let word = &src[start..i];
1213 match word {
1214 "true" => toks.push(EToken::True),
1215 "false" => toks.push(EToken::False),
1216 "nil" => toks.push(EToken::Nil),
1217 "and" => toks.push(EToken::AndKw),
1218 "or" => toks.push(EToken::OrKw),
1219 "not" => toks.push(EToken::NotKw),
1220 other => toks.push(EToken::Ident(other.to_string())),
1221 }
1222 }
1223 _ => {
1224 return Err(TemplateError::new(
1225 line,
1226 col,
1227 format!("unexpected character `{}` in expression", b as char),
1228 ));
1229 }
1230 }
1231 }
1232 Ok(toks)
1233}
1234
1235struct ExprParser<'a> {
1236 toks: &'a [EToken],
1237 pos: usize,
1238 line: usize,
1239 col: usize,
1240}
1241
1242impl<'a> ExprParser<'a> {
1243 fn peek(&self) -> Option<&EToken> {
1244 self.toks.get(self.pos)
1245 }
1246 fn eat(&mut self, t: &EToken) -> bool {
1247 if self.peek() == Some(t) {
1248 self.pos += 1;
1249 true
1250 } else {
1251 false
1252 }
1253 }
1254 fn err(&self, m: impl Into<String>) -> TemplateError {
1255 TemplateError::new(self.line, self.col, m)
1256 }
1257
1258 fn parse_filter(&mut self) -> Result<Expr, TemplateError> {
1259 let mut left = self.parse_or()?;
1260 while self.eat(&EToken::Pipe) {
1261 let name = match self.peek() {
1262 Some(EToken::Ident(n)) => n.clone(),
1263 _ => return Err(self.err("expected filter name after `|`")),
1264 };
1265 self.pos += 1;
1266 let mut args = Vec::new();
1267 if self.eat(&EToken::Colon) {
1268 loop {
1269 let a = self.parse_or()?;
1270 args.push(a);
1271 if !self.eat(&EToken::Comma) {
1272 break;
1273 }
1274 }
1275 }
1276 left = Expr::Filter(Box::new(left), name, args);
1277 }
1278 Ok(left)
1279 }
1280
1281 fn parse_or(&mut self) -> Result<Expr, TemplateError> {
1282 let mut left = self.parse_and()?;
1283 while self.eat(&EToken::OrKw) {
1284 let right = self.parse_and()?;
1285 left = Expr::Binary(BinOp::Or, Box::new(left), Box::new(right));
1286 }
1287 Ok(left)
1288 }
1289
1290 fn parse_and(&mut self) -> Result<Expr, TemplateError> {
1291 let mut left = self.parse_not()?;
1292 while self.eat(&EToken::AndKw) {
1293 let right = self.parse_not()?;
1294 left = Expr::Binary(BinOp::And, Box::new(left), Box::new(right));
1295 }
1296 Ok(left)
1297 }
1298
1299 fn parse_not(&mut self) -> Result<Expr, TemplateError> {
1300 if self.eat(&EToken::Bang) || self.eat(&EToken::NotKw) {
1301 let inner = self.parse_not()?;
1302 return Ok(Expr::Unary(UnOp::Not, Box::new(inner)));
1303 }
1304 self.parse_cmp()
1305 }
1306
1307 fn parse_cmp(&mut self) -> Result<Expr, TemplateError> {
1308 let left = self.parse_unary()?;
1309 let op = match self.peek() {
1310 Some(EToken::EqEq) => Some(BinOp::Eq),
1311 Some(EToken::BangEq) => Some(BinOp::Neq),
1312 Some(EToken::Lt) => Some(BinOp::Lt),
1313 Some(EToken::Le) => Some(BinOp::Le),
1314 Some(EToken::Gt) => Some(BinOp::Gt),
1315 Some(EToken::Ge) => Some(BinOp::Ge),
1316 _ => None,
1317 };
1318 if let Some(op) = op {
1319 self.pos += 1;
1320 let right = self.parse_unary()?;
1321 return Ok(Expr::Binary(op, Box::new(left), Box::new(right)));
1322 }
1323 Ok(left)
1324 }
1325
1326 fn parse_unary(&mut self) -> Result<Expr, TemplateError> {
1327 self.parse_primary()
1328 }
1329
1330 fn parse_primary(&mut self) -> Result<Expr, TemplateError> {
1331 let tok = self
1332 .peek()
1333 .cloned()
1334 .ok_or_else(|| self.err("expected expression"))?;
1335 self.pos += 1;
1336 let base = match tok {
1337 EToken::Nil => Expr::Nil,
1338 EToken::True => Expr::Bool(true),
1339 EToken::False => Expr::Bool(false),
1340 EToken::Int(n) => Expr::Int(n),
1341 EToken::Float(f) => Expr::Float(f),
1342 EToken::Str(s) => Expr::Str(s),
1343 EToken::LParen => {
1344 let e = self.parse_or()?;
1345 if !self.eat(&EToken::RParen) {
1346 return Err(self.err("expected `)`"));
1347 }
1348 e
1349 }
1350 EToken::Ident(name) => self.parse_path(name)?,
1351 EToken::Bang | EToken::NotKw => {
1352 let inner = self.parse_primary()?;
1353 Expr::Unary(UnOp::Not, Box::new(inner))
1354 }
1355 other => return Err(self.err(format!("unexpected token `{:?}`", other))),
1356 };
1357 Ok(base)
1358 }
1359
1360 fn parse_path(&mut self, head: String) -> Result<Expr, TemplateError> {
1361 let mut segs = vec![PathSeg::Field(head)];
1362 loop {
1363 match self.peek() {
1364 Some(EToken::Dot) => {
1365 self.pos += 1;
1366 match self.peek().cloned() {
1367 Some(EToken::Ident(n)) => {
1368 self.pos += 1;
1369 segs.push(PathSeg::Field(n));
1370 }
1371 _ => return Err(self.err("expected identifier after `.`")),
1372 }
1373 }
1374 Some(EToken::LBracket) => {
1375 self.pos += 1;
1376 match self.peek().cloned() {
1377 Some(EToken::Int(n)) => {
1378 self.pos += 1;
1379 segs.push(PathSeg::Index(n));
1380 }
1381 Some(EToken::Str(s)) => {
1382 self.pos += 1;
1383 segs.push(PathSeg::Key(s));
1384 }
1385 _ => return Err(self.err("expected integer or string inside `[...]`")),
1386 }
1387 if !self.eat(&EToken::RBracket) {
1388 return Err(self.err("expected `]`"));
1389 }
1390 }
1391 _ => break,
1392 }
1393 }
1394 Ok(Expr::Path(segs))
1395 }
1396}
1397
1398#[derive(Default, Debug, Clone)]
1403struct Scope<'a> {
1404 root: Option<&'a BTreeMap<String, VmValue>>,
1406 overrides: Vec<BTreeMap<String, VmValue>>,
1408}
1409
1410impl<'a> Scope<'a> {
1411 fn new(root: Option<&'a BTreeMap<String, VmValue>>) -> Self {
1412 Self {
1413 root,
1414 overrides: Vec::new(),
1415 }
1416 }
1417
1418 fn lookup(&self, name: &str) -> Option<VmValue> {
1419 for layer in self.overrides.iter().rev() {
1420 if let Some(v) = layer.get(name) {
1421 return Some(v.clone());
1422 }
1423 }
1424 self.root.and_then(|m| m.get(name)).cloned()
1425 }
1426
1427 fn push(&mut self, layer: BTreeMap<String, VmValue>) {
1428 self.overrides.push(layer);
1429 }
1430
1431 fn pop(&mut self) {
1432 self.overrides.pop();
1433 }
1434
1435 fn flatten(&self) -> BTreeMap<String, VmValue> {
1438 let mut out = BTreeMap::new();
1439 if let Some(r) = self.root {
1440 for (k, v) in r.iter() {
1441 out.insert(k.clone(), v.clone());
1442 }
1443 }
1444 for layer in &self.overrides {
1445 for (k, v) in layer {
1446 out.insert(k.clone(), v.clone());
1447 }
1448 }
1449 out
1450 }
1451}
1452
1453struct RenderCtx {
1454 base: Option<PathBuf>,
1455 include_stack: Vec<PathBuf>,
1456 current_path: Option<PathBuf>,
1457 current_include_parent: Option<Box<PromptSourceSpan>>,
1463}
1464
1465fn current_template_uri(rc: &RenderCtx) -> String {
1469 rc.current_path
1470 .as_deref()
1471 .and_then(|p| p.to_str().map(|s| s.to_string()))
1472 .unwrap_or_default()
1473}
1474
1475fn render_nodes(
1476 nodes: &[Node],
1477 scope: &mut Scope<'_>,
1478 rc: &mut RenderCtx,
1479 out: &mut String,
1480 mut spans: Option<&mut Vec<PromptSourceSpan>>,
1481) -> Result<(), TemplateError> {
1482 for n in nodes {
1483 render_node(n, scope, rc, out, spans.as_deref_mut())?;
1484 }
1485 Ok(())
1486}
1487
1488fn render_node(
1489 node: &Node,
1490 scope: &mut Scope<'_>,
1491 rc: &mut RenderCtx,
1492 out: &mut String,
1493 mut spans: Option<&mut Vec<PromptSourceSpan>>,
1494) -> Result<(), TemplateError> {
1495 let start = out.len();
1500 match node {
1501 Node::Text(s) => {
1502 out.push_str(s);
1503 if let Some(spans) = spans.as_deref_mut() {
1504 spans.push(PromptSourceSpan {
1510 template_line: 0,
1511 template_col: 0,
1512 output_start: start,
1513 output_end: out.len(),
1514 kind: PromptSpanKind::Text,
1515 parent_span: rc.current_include_parent.clone(),
1516 template_uri: current_template_uri(rc),
1517 bound_value: None,
1518 });
1519 }
1520 }
1521 Node::Expr { expr, line, col } => {
1522 let v = eval_expr(expr, scope, *line, *col)?;
1523 let rendered = display_value(&v);
1524 out.push_str(&rendered);
1525 if let Some(spans) = spans.as_deref_mut() {
1526 spans.push(PromptSourceSpan {
1527 template_line: *line,
1528 template_col: *col,
1529 output_start: start,
1530 output_end: out.len(),
1531 kind: PromptSpanKind::Expr,
1532 parent_span: rc.current_include_parent.clone(),
1533 template_uri: current_template_uri(rc),
1534 bound_value: Some(truncate_for_preview(&rendered)),
1535 });
1536 }
1537 }
1538 Node::LegacyBareInterp { ident } => {
1539 let (rendered, preview) = match scope.lookup(ident) {
1540 Some(v) => {
1541 let s = display_value(&v);
1542 (s.clone(), Some(truncate_for_preview(&s)))
1543 }
1544 None => (format!("{{{{{ident}}}}}"), None),
1545 };
1546 out.push_str(&rendered);
1547 if let Some(spans) = spans.as_deref_mut() {
1548 spans.push(PromptSourceSpan {
1549 template_line: 0,
1550 template_col: 0,
1551 output_start: start,
1552 output_end: out.len(),
1553 kind: PromptSpanKind::LegacyBareInterp,
1554 parent_span: rc.current_include_parent.clone(),
1555 template_uri: current_template_uri(rc),
1556 bound_value: preview,
1557 });
1558 }
1559 }
1560 Node::If {
1561 branches,
1562 else_branch,
1563 line,
1564 col,
1565 } => {
1566 let mut matched = false;
1567 for (cond, body) in branches {
1568 let v = eval_expr(cond, scope, *line, *col)?;
1569 if truthy(&v) {
1570 render_nodes(body, scope, rc, out, spans.as_deref_mut())?;
1571 matched = true;
1572 break;
1573 }
1574 }
1575 if !matched {
1576 if let Some(eb) = else_branch {
1577 render_nodes(eb, scope, rc, out, spans.as_deref_mut())?;
1578 }
1579 }
1580 if let Some(spans) = spans.as_deref_mut() {
1581 spans.push(PromptSourceSpan {
1582 template_line: *line,
1583 template_col: *col,
1584 output_start: start,
1585 output_end: out.len(),
1586 kind: PromptSpanKind::If,
1587 parent_span: rc.current_include_parent.clone(),
1588 template_uri: current_template_uri(rc),
1589 bound_value: None,
1590 });
1591 }
1592 }
1593 Node::For {
1594 value_var,
1595 key_var,
1596 iter,
1597 body,
1598 empty,
1599 line,
1600 col,
1601 } => {
1602 let v = eval_expr(iter, scope, *line, *col)?;
1603 let items: Vec<(VmValue, VmValue)> =
1604 iterable_items(&v).map_err(|m| TemplateError::new(*line, *col, m))?;
1605 if items.is_empty() {
1606 if let Some(eb) = empty {
1607 render_nodes(eb, scope, rc, out, spans.as_deref_mut())?;
1608 }
1609 } else {
1610 let length = items.len() as i64;
1611 for (idx, (k, val)) in items.iter().enumerate() {
1612 let mut layer: BTreeMap<String, VmValue> = BTreeMap::new();
1613 layer.insert(value_var.clone(), val.clone());
1614 if let Some(kv) = key_var {
1615 layer.insert(kv.clone(), k.clone());
1616 }
1617 let mut loop_map: BTreeMap<String, VmValue> = BTreeMap::new();
1618 loop_map.insert("index".into(), VmValue::Int(idx as i64 + 1));
1619 loop_map.insert("index0".into(), VmValue::Int(idx as i64));
1620 loop_map.insert("first".into(), VmValue::Bool(idx == 0));
1621 loop_map.insert("last".into(), VmValue::Bool(idx as i64 == length - 1));
1622 loop_map.insert("length".into(), VmValue::Int(length));
1623 layer.insert("loop".into(), VmValue::Dict(Rc::new(loop_map)));
1624 scope.push(layer);
1625 let iter_start = out.len();
1626 let res = render_nodes(body, scope, rc, out, spans.as_deref_mut());
1627 scope.pop();
1628 res?;
1629 if let Some(spans) = spans.as_deref_mut() {
1630 spans.push(PromptSourceSpan {
1631 template_line: *line,
1632 template_col: *col,
1633 output_start: iter_start,
1634 output_end: out.len(),
1635 kind: PromptSpanKind::ForIteration,
1636 parent_span: rc.current_include_parent.clone(),
1637 template_uri: current_template_uri(rc),
1638 bound_value: None,
1639 });
1640 }
1641 }
1642 }
1643 }
1644 Node::Include {
1645 path,
1646 with,
1647 line,
1648 col,
1649 } => {
1650 let path_val = eval_expr(path, scope, *line, *col)?;
1651 let path_str = match path_val {
1652 VmValue::String(s) => s.to_string(),
1653 other => {
1654 return Err(TemplateError::new(
1655 *line,
1656 *col,
1657 format!("include path must be a string (got {})", other.type_name()),
1658 ))
1659 }
1660 };
1661 let resolved: PathBuf = if Path::new(&path_str).is_absolute() {
1664 PathBuf::from(&path_str)
1665 } else if let Some(base) = &rc.base {
1666 base.join(&path_str)
1667 } else {
1668 crate::stdlib::process::resolve_source_asset_path(&path_str)
1669 };
1670 let canonical = resolved.canonicalize().unwrap_or(resolved.clone());
1671 if rc.include_stack.iter().any(|p| p == &canonical) {
1672 let chain = rc
1673 .include_stack
1674 .iter()
1675 .map(|p| p.display().to_string())
1676 .collect::<Vec<_>>()
1677 .join(" → ");
1678 return Err(TemplateError::new(
1679 *line,
1680 *col,
1681 format!(
1682 "circular include detected: {chain} → {}",
1683 canonical.display()
1684 ),
1685 ));
1686 }
1687 if rc.include_stack.len() > 32 {
1688 return Err(TemplateError::new(
1689 *line,
1690 *col,
1691 "include depth exceeded (32 levels)",
1692 ));
1693 }
1694 let contents = std::fs::read_to_string(&resolved).map_err(|e| {
1695 TemplateError::new(
1696 *line,
1697 *col,
1698 format!(
1699 "failed to read included template {}: {e}",
1700 resolved.display()
1701 ),
1702 )
1703 })?;
1704 let new_base = resolved.parent().map(Path::to_path_buf);
1705 let mut child_bindings = scope.flatten();
1707 if let Some(pairs) = with {
1708 for (k, e) in pairs {
1709 let v = eval_expr(e, scope, *line, *col)?;
1710 child_bindings.insert(k.clone(), v);
1711 }
1712 }
1713 let child_nodes = parse(&contents).map_err(|mut e| {
1714 if e.path.is_none() {
1715 e.path = Some(resolved.clone());
1716 }
1717 e
1718 })?;
1719 let mut child_scope = Scope::new(Some(&child_bindings));
1720 let saved_base = rc.base.clone();
1721 let saved_current = rc.current_path.clone();
1722 let saved_parent = rc.current_include_parent.clone();
1723 let include_call_span = PromptSourceSpan {
1729 template_line: *line,
1730 template_col: *col,
1731 output_start: start,
1732 output_end: start,
1733 kind: PromptSpanKind::Include,
1734 bound_value: None,
1735 parent_span: saved_parent.clone(),
1736 template_uri: current_template_uri(rc),
1737 };
1738 rc.base = new_base;
1739 rc.current_path = Some(resolved.clone());
1740 rc.current_include_parent = Some(Box::new(include_call_span));
1741 rc.include_stack.push(canonical);
1742 let res = render_nodes(
1743 &child_nodes,
1744 &mut child_scope,
1745 rc,
1746 out,
1747 spans.as_deref_mut(),
1748 );
1749 rc.include_stack.pop();
1750 rc.base = saved_base;
1751 rc.current_path = saved_current;
1752 rc.current_include_parent = saved_parent;
1753 res?;
1754 if let Some(spans) = spans.as_mut() {
1755 spans.push(PromptSourceSpan {
1756 template_line: *line,
1757 template_col: *col,
1758 output_start: start,
1759 output_end: out.len(),
1760 kind: PromptSpanKind::Include,
1761 parent_span: rc.current_include_parent.clone(),
1762 template_uri: current_template_uri(rc),
1763 bound_value: None,
1764 });
1765 }
1766 }
1767 }
1768 Ok(())
1769}
1770
1771fn truncate_for_preview(s: &str) -> String {
1775 const MAX: usize = 80;
1776 if s.chars().count() <= MAX {
1777 return s.to_string();
1778 }
1779 let truncated: String = s.chars().take(MAX - 1).collect();
1780 format!("{truncated}…")
1781}
1782
1783fn eval_expr(
1784 expr: &Expr,
1785 scope: &Scope<'_>,
1786 line: usize,
1787 col: usize,
1788) -> Result<VmValue, TemplateError> {
1789 match expr {
1790 Expr::Nil => Ok(VmValue::Nil),
1791 Expr::Bool(b) => Ok(VmValue::Bool(*b)),
1792 Expr::Int(n) => Ok(VmValue::Int(*n)),
1793 Expr::Float(f) => Ok(VmValue::Float(*f)),
1794 Expr::Str(s) => Ok(VmValue::String(Rc::from(s.as_str()))),
1795 Expr::Path(segs) => Ok(resolve_path(segs, scope)),
1796 Expr::Unary(UnOp::Not, inner) => {
1797 let v = eval_expr(inner, scope, line, col)?;
1798 Ok(VmValue::Bool(!truthy(&v)))
1799 }
1800 Expr::Binary(op, a, b) => {
1801 match op {
1803 BinOp::And => {
1804 let av = eval_expr(a, scope, line, col)?;
1805 if !truthy(&av) {
1806 return Ok(av);
1807 }
1808 return eval_expr(b, scope, line, col);
1809 }
1810 BinOp::Or => {
1811 let av = eval_expr(a, scope, line, col)?;
1812 if truthy(&av) {
1813 return Ok(av);
1814 }
1815 return eval_expr(b, scope, line, col);
1816 }
1817 _ => {}
1818 }
1819 let av = eval_expr(a, scope, line, col)?;
1820 let bv = eval_expr(b, scope, line, col)?;
1821 Ok(apply_cmp(*op, &av, &bv))
1822 }
1823 Expr::Filter(inner, name, args) => {
1824 let v = eval_expr(inner, scope, line, col)?;
1825 let arg_vals = args
1826 .iter()
1827 .map(|e| eval_expr(e, scope, line, col))
1828 .collect::<Result<Vec<_>, _>>()?;
1829 apply_filter(name, &v, &arg_vals, line, col)
1830 }
1831 }
1832}
1833
1834fn resolve_path(segs: &[PathSeg], scope: &Scope<'_>) -> VmValue {
1835 let mut cur: VmValue = match segs.first() {
1836 Some(PathSeg::Field(n)) => match scope.lookup(n) {
1837 Some(v) => v,
1838 None => return VmValue::Nil,
1839 },
1840 _ => return VmValue::Nil,
1841 };
1842 for seg in &segs[1..] {
1843 cur = match (seg, &cur) {
1844 (PathSeg::Field(n), VmValue::Dict(d)) => d.get(n).cloned().unwrap_or(VmValue::Nil),
1845 (PathSeg::Key(k), VmValue::Dict(d)) => d.get(k).cloned().unwrap_or(VmValue::Nil),
1846 (PathSeg::Index(i), VmValue::List(items)) => {
1847 let idx = if *i < 0 { items.len() as i64 + *i } else { *i };
1848 if idx < 0 || (idx as usize) >= items.len() {
1849 VmValue::Nil
1850 } else {
1851 items[idx as usize].clone()
1852 }
1853 }
1854 (PathSeg::Index(i), VmValue::String(s)) => {
1855 let chars: Vec<char> = s.chars().collect();
1856 let idx = if *i < 0 { chars.len() as i64 + *i } else { *i };
1857 if idx < 0 || (idx as usize) >= chars.len() {
1858 VmValue::Nil
1859 } else {
1860 VmValue::String(Rc::from(chars[idx as usize].to_string()))
1861 }
1862 }
1863 _ => VmValue::Nil,
1864 };
1865 }
1866 cur
1867}
1868
1869fn truthy(v: &VmValue) -> bool {
1870 match v {
1871 VmValue::Nil => false,
1872 VmValue::Bool(b) => *b,
1873 VmValue::Int(n) => *n != 0,
1874 VmValue::Float(f) => *f != 0.0,
1875 VmValue::String(s) => !s.trim().is_empty(),
1876 VmValue::List(items) => !items.is_empty(),
1877 VmValue::Dict(d) => !d.is_empty(),
1878 _ => true,
1879 }
1880}
1881
1882fn apply_cmp(op: BinOp, a: &VmValue, b: &VmValue) -> VmValue {
1883 match op {
1884 BinOp::Eq => VmValue::Bool(values_equal(a, b)),
1885 BinOp::Neq => VmValue::Bool(!values_equal(a, b)),
1886 BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => {
1887 let ord = compare(a, b);
1888 match (op, ord) {
1889 (BinOp::Lt, Some(o)) => VmValue::Bool(o == std::cmp::Ordering::Less),
1890 (BinOp::Le, Some(o)) => VmValue::Bool(o != std::cmp::Ordering::Greater),
1891 (BinOp::Gt, Some(o)) => VmValue::Bool(o == std::cmp::Ordering::Greater),
1892 (BinOp::Ge, Some(o)) => VmValue::Bool(o != std::cmp::Ordering::Less),
1893 _ => VmValue::Bool(false),
1894 }
1895 }
1896 BinOp::And | BinOp::Or => unreachable!(),
1897 }
1898}
1899
1900fn compare(a: &VmValue, b: &VmValue) -> Option<std::cmp::Ordering> {
1901 match (a, b) {
1902 (VmValue::Int(x), VmValue::Int(y)) => Some(x.cmp(y)),
1903 (VmValue::Float(x), VmValue::Float(y)) => x.partial_cmp(y),
1904 (VmValue::Int(x), VmValue::Float(y)) => (*x as f64).partial_cmp(y),
1905 (VmValue::Float(x), VmValue::Int(y)) => x.partial_cmp(&(*y as f64)),
1906 (VmValue::String(x), VmValue::String(y)) => Some(x.as_ref().cmp(y.as_ref())),
1907 _ => None,
1908 }
1909}
1910
1911fn iterable_items(v: &VmValue) -> Result<Vec<(VmValue, VmValue)>, String> {
1912 match v {
1913 VmValue::List(items) => Ok(items
1914 .iter()
1915 .enumerate()
1916 .map(|(i, it)| (VmValue::Int(i as i64), it.clone()))
1917 .collect()),
1918 VmValue::Dict(d) => Ok(d
1919 .iter()
1920 .map(|(k, v)| (VmValue::String(Rc::from(k.as_str())), v.clone()))
1921 .collect()),
1922 VmValue::Set(items) => Ok(items
1923 .iter()
1924 .enumerate()
1925 .map(|(i, it)| (VmValue::Int(i as i64), it.clone()))
1926 .collect()),
1927 VmValue::Range(r) => {
1928 let mut out = Vec::new();
1929 let len = r.len();
1930 for i in 0..len {
1931 if let Some(v) = r.get(i) {
1932 out.push((VmValue::Int(i), VmValue::Int(v)));
1933 }
1934 }
1935 Ok(out)
1936 }
1937 VmValue::Nil => Ok(Vec::new()),
1938 other => Err(format!(
1939 "cannot iterate over {} — expected list, dict, set, or range",
1940 other.type_name()
1941 )),
1942 }
1943}
1944
1945fn display_value(v: &VmValue) -> String {
1946 match v {
1947 VmValue::Nil => String::new(), other => other.display(),
1949 }
1950}
1951
1952fn apply_filter(
1957 name: &str,
1958 v: &VmValue,
1959 args: &[VmValue],
1960 line: usize,
1961 col: usize,
1962) -> Result<VmValue, TemplateError> {
1963 let bad_arity = || {
1964 TemplateError::new(
1965 line,
1966 col,
1967 format!("filter `{name}` got wrong number of arguments"),
1968 )
1969 };
1970 let need = |n: usize, args: &[VmValue]| -> Result<(), TemplateError> {
1971 if args.len() == n {
1972 Ok(())
1973 } else {
1974 Err(bad_arity())
1975 }
1976 };
1977 let str_of = |v: &VmValue| -> String { display_value(v) };
1978 match name {
1979 "upper" => {
1980 need(0, args)?;
1981 Ok(VmValue::String(Rc::from(str_of(v).to_uppercase())))
1982 }
1983 "lower" => {
1984 need(0, args)?;
1985 Ok(VmValue::String(Rc::from(str_of(v).to_lowercase())))
1986 }
1987 "trim" => {
1988 need(0, args)?;
1989 Ok(VmValue::String(Rc::from(str_of(v).trim())))
1990 }
1991 "capitalize" => {
1992 need(0, args)?;
1993 let s = str_of(v);
1994 let mut out = String::with_capacity(s.len());
1995 let mut chars = s.chars();
1996 if let Some(c) = chars.next() {
1997 out.extend(c.to_uppercase());
1998 }
1999 for c in chars {
2000 out.extend(c.to_lowercase());
2001 }
2002 Ok(VmValue::String(Rc::from(out)))
2003 }
2004 "title" => {
2005 need(0, args)?;
2006 let s = str_of(v);
2007 let mut out = String::with_capacity(s.len());
2008 let mut at_start = true;
2009 for c in s.chars() {
2010 if c.is_whitespace() {
2011 at_start = true;
2012 out.push(c);
2013 } else if at_start {
2014 out.extend(c.to_uppercase());
2015 at_start = false;
2016 } else {
2017 out.extend(c.to_lowercase());
2018 }
2019 }
2020 Ok(VmValue::String(Rc::from(out)))
2021 }
2022 "length" => {
2023 need(0, args)?;
2024 let n: i64 = match v {
2025 VmValue::String(s) => s.chars().count() as i64,
2026 VmValue::List(items) => items.len() as i64,
2027 VmValue::Set(items) => items.len() as i64,
2028 VmValue::Dict(d) => d.len() as i64,
2029 VmValue::Range(r) => r.len(),
2030 VmValue::Nil => 0,
2031 other => {
2032 return Err(TemplateError::new(
2033 line,
2034 col,
2035 format!("`length` not defined for {}", other.type_name()),
2036 ))
2037 }
2038 };
2039 Ok(VmValue::Int(n))
2040 }
2041 "first" => {
2042 need(0, args)?;
2043 Ok(match v {
2044 VmValue::List(items) => items.first().cloned().unwrap_or(VmValue::Nil),
2045 VmValue::Set(items) => items.first().cloned().unwrap_or(VmValue::Nil),
2046 VmValue::String(s) => s
2047 .chars()
2048 .next()
2049 .map(|c| VmValue::String(Rc::from(c.to_string())))
2050 .unwrap_or(VmValue::Nil),
2051 _ => VmValue::Nil,
2052 })
2053 }
2054 "last" => {
2055 need(0, args)?;
2056 Ok(match v {
2057 VmValue::List(items) => items.last().cloned().unwrap_or(VmValue::Nil),
2058 VmValue::Set(items) => items.last().cloned().unwrap_or(VmValue::Nil),
2059 VmValue::String(s) => s
2060 .chars()
2061 .last()
2062 .map(|c| VmValue::String(Rc::from(c.to_string())))
2063 .unwrap_or(VmValue::Nil),
2064 _ => VmValue::Nil,
2065 })
2066 }
2067 "reverse" => {
2068 need(0, args)?;
2069 Ok(match v {
2070 VmValue::List(items) => {
2071 let mut out: Vec<VmValue> = items.as_ref().clone();
2072 out.reverse();
2073 VmValue::List(Rc::new(out))
2074 }
2075 VmValue::String(s) => {
2076 VmValue::String(Rc::from(s.chars().rev().collect::<String>()))
2077 }
2078 _ => v.clone(),
2079 })
2080 }
2081 "join" => {
2082 need(1, args)?;
2083 let sep = str_of(&args[0]);
2084 let parts: Vec<String> = match v {
2085 VmValue::List(items) => items.iter().map(str_of).collect(),
2086 VmValue::Set(items) => items.iter().map(str_of).collect(),
2087 VmValue::String(s) => return Ok(VmValue::String(s.clone())),
2088 _ => {
2089 return Err(TemplateError::new(
2090 line,
2091 col,
2092 format!("`join` requires a list (got {})", v.type_name()),
2093 ))
2094 }
2095 };
2096 Ok(VmValue::String(Rc::from(parts.join(&sep))))
2097 }
2098 "default" => {
2099 need(1, args)?;
2100 if truthy(v) {
2101 Ok(v.clone())
2102 } else {
2103 Ok(args[0].clone())
2104 }
2105 }
2106 "json" => {
2107 if args.len() > 1 {
2108 return Err(bad_arity());
2109 }
2110 let pretty = args.first().map(truthy).unwrap_or(false);
2111 let jv = crate::llm::helpers::vm_value_to_json(v);
2112 let s = if pretty {
2113 serde_json::to_string_pretty(&jv)
2114 } else {
2115 serde_json::to_string(&jv)
2116 }
2117 .map_err(|e| TemplateError::new(line, col, format!("json serialization: {e}")))?;
2118 Ok(VmValue::String(Rc::from(s)))
2119 }
2120 "indent" => {
2121 if args.is_empty() || args.len() > 2 {
2122 return Err(bad_arity());
2123 }
2124 let n = match &args[0] {
2125 VmValue::Int(n) => (*n).max(0) as usize,
2126 _ => {
2127 return Err(TemplateError::new(
2128 line,
2129 col,
2130 "`indent` requires an integer width",
2131 ))
2132 }
2133 };
2134 let indent_first = args.get(1).map(truthy).unwrap_or(false);
2135 let pad: String = " ".repeat(n);
2136 let s = str_of(v);
2137 let mut out = String::with_capacity(s.len() + n * 4);
2138 for (i, line) in s.split('\n').enumerate() {
2139 if i > 0 {
2140 out.push('\n');
2141 }
2142 if !line.is_empty() && (i > 0 || indent_first) {
2143 out.push_str(&pad);
2144 }
2145 out.push_str(line);
2146 }
2147 Ok(VmValue::String(Rc::from(out)))
2148 }
2149 "lines" => {
2150 need(0, args)?;
2151 let s = str_of(v);
2152 let list: Vec<VmValue> = s
2153 .split('\n')
2154 .map(|p| VmValue::String(Rc::from(p)))
2155 .collect();
2156 Ok(VmValue::List(Rc::new(list)))
2157 }
2158 "escape_md" => {
2159 need(0, args)?;
2160 let s = str_of(v);
2161 let mut out = String::with_capacity(s.len() + 8);
2162 for c in s.chars() {
2163 match c {
2164 '\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+'
2165 | '-' | '.' | '!' | '|' | '<' | '>' => {
2166 out.push('\\');
2167 out.push(c);
2168 }
2169 _ => out.push(c),
2170 }
2171 }
2172 Ok(VmValue::String(Rc::from(out)))
2173 }
2174 "replace" => {
2175 need(2, args)?;
2176 let s = str_of(v);
2177 let from = str_of(&args[0]);
2178 let to = str_of(&args[1]);
2179 Ok(VmValue::String(Rc::from(s.replace(&from, &to))))
2180 }
2181 other => Err(TemplateError::new(
2182 line,
2183 col,
2184 format!("unknown filter `{other}`"),
2185 )),
2186 }
2187}
2188
2189fn split_top_level(s: &str, delim: char) -> Vec<&str> {
2194 let mut out = Vec::new();
2195 let mut depth = 0i32;
2196 let mut in_str = false;
2197 let mut quote = '"';
2198 let bytes = s.as_bytes();
2199 let mut start = 0;
2200 let mut i = 0;
2201 while i < bytes.len() {
2202 let b = bytes[i] as char;
2203 if in_str {
2204 if b == '\\' {
2205 i += 2;
2206 continue;
2207 }
2208 if b == quote {
2209 in_str = false;
2210 }
2211 i += 1;
2212 continue;
2213 }
2214 match b {
2215 '"' | '\'' => {
2216 in_str = true;
2217 quote = b;
2218 }
2219 '(' | '[' | '{' => depth += 1,
2220 ')' | ']' | '}' => depth -= 1,
2221 c if c == delim && depth == 0 => {
2222 out.push(&s[start..i]);
2223 start = i + 1;
2224 }
2225 _ => {}
2226 }
2227 i += 1;
2228 }
2229 out.push(&s[start..]);
2230 out
2231}
2232
2233fn split_once_top_level(s: &str, delim: char) -> Option<(&str, &str)> {
2234 let mut depth = 0i32;
2235 let mut in_str = false;
2236 let mut quote = '"';
2237 let bytes = s.as_bytes();
2238 let mut i = 0;
2239 while i < bytes.len() {
2240 let b = bytes[i] as char;
2241 if in_str {
2242 if b == '\\' {
2243 i += 2;
2244 continue;
2245 }
2246 if b == quote {
2247 in_str = false;
2248 }
2249 i += 1;
2250 continue;
2251 }
2252 match b {
2253 '"' | '\'' => {
2254 in_str = true;
2255 quote = b;
2256 }
2257 '(' | '[' | '{' => depth += 1,
2258 ')' | ']' | '}' => depth -= 1,
2259 c if c == delim && depth == 0 => {
2260 return Some((&s[..i], &s[i + 1..]));
2261 }
2262 _ => {}
2263 }
2264 i += 1;
2265 }
2266 None
2267}
2268
2269fn split_once_keyword<'a>(s: &'a str, kw: &str) -> Option<(&'a str, &'a str)> {
2270 let mut depth = 0i32;
2272 let mut in_str = false;
2273 let mut quote = '"';
2274 let bytes = s.as_bytes();
2275 let kw_bytes = kw.as_bytes();
2276 let mut i = 0;
2277 while i + kw_bytes.len() <= bytes.len() {
2278 let b = bytes[i] as char;
2279 if in_str {
2280 if b == '\\' {
2281 i += 2;
2282 continue;
2283 }
2284 if b == quote {
2285 in_str = false;
2286 }
2287 i += 1;
2288 continue;
2289 }
2290 match b {
2291 '"' | '\'' => {
2292 in_str = true;
2293 quote = b;
2294 i += 1;
2295 continue;
2296 }
2297 '(' | '[' | '{' => {
2298 depth += 1;
2299 i += 1;
2300 continue;
2301 }
2302 ')' | ']' | '}' => {
2303 depth -= 1;
2304 i += 1;
2305 continue;
2306 }
2307 _ => {}
2308 }
2309 if depth == 0 && &bytes[i..i + kw_bytes.len()] == kw_bytes {
2310 return Some((&s[..i], &s[i + kw_bytes.len()..]));
2311 }
2312 i += 1;
2313 }
2314 None
2315}
2316
2317#[cfg(test)]
2322mod tests {
2323 use super::*;
2324
2325 fn dict(pairs: &[(&str, VmValue)]) -> BTreeMap<String, VmValue> {
2326 pairs
2327 .iter()
2328 .map(|(k, v)| (k.to_string(), v.clone()))
2329 .collect()
2330 }
2331
2332 fn s(v: &str) -> VmValue {
2333 VmValue::String(Rc::from(v))
2334 }
2335
2336 fn render(tpl: &str, b: &BTreeMap<String, VmValue>) -> String {
2337 render_template_result(tpl, Some(b), None, None).unwrap()
2338 }
2339
2340 fn render_with_spans(
2341 tpl: &str,
2342 b: &BTreeMap<String, VmValue>,
2343 ) -> (String, Vec<PromptSourceSpan>) {
2344 render_template_with_provenance(tpl, Some(b), None, None, true).unwrap()
2345 }
2346
2347 #[test]
2348 fn bare_interp() {
2349 let b = dict(&[("name", s("Alice"))]);
2350 assert_eq!(render("hi {{name}}!", &b), "hi Alice!");
2351 }
2352
2353 #[test]
2354 fn provenance_expr_span_matches_output_range() {
2355 let mut user = BTreeMap::new();
2359 user.insert("name".to_string(), s("alice"));
2360 let b = dict(&[
2361 ("user", VmValue::Dict(Rc::new(user))),
2362 ("count", VmValue::Int(42)),
2363 ]);
2364 let (out, spans) =
2365 render_with_spans("hello {{ user.name }} ({{ count | default: 0 }})", &b);
2366 assert_eq!(out, "hello alice (42)");
2367
2368 let expr_spans: Vec<_> = spans
2369 .iter()
2370 .filter(|s| s.kind == PromptSpanKind::Expr)
2371 .collect();
2372 assert_eq!(expr_spans.len(), 2);
2373
2374 let user_span = expr_spans
2377 .iter()
2378 .find(|s| &out[s.output_start..s.output_end] == "alice")
2379 .expect("user expr span");
2380 assert!(user_span.template_line >= 1);
2381 assert_eq!(user_span.bound_value.as_deref(), Some("alice"));
2382
2383 let count_span = expr_spans
2384 .iter()
2385 .find(|s| &out[s.output_start..s.output_end] == "42")
2386 .expect("count expr span");
2387 assert_eq!(count_span.bound_value.as_deref(), Some("42"));
2388 }
2389
2390 #[test]
2391 fn provenance_legacy_bare_interp_span_tracked() {
2392 let b = dict(&[("name", s("Alice"))]);
2393 let (out, spans) = render_with_spans("hi {{name}}!", &b);
2394 assert_eq!(out, "hi Alice!");
2395
2396 let bare = spans
2397 .iter()
2398 .find(|s| s.kind == PromptSpanKind::LegacyBareInterp)
2399 .expect("legacy bare span");
2400 assert_eq!(&out[bare.output_start..bare.output_end], "Alice");
2401 assert_eq!(bare.bound_value.as_deref(), Some("Alice"));
2402 }
2403
2404 #[test]
2405 fn provenance_includes_loop_iterations() {
2406 let b = dict(&[(
2407 "items",
2408 VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")])),
2409 )]);
2410 let tpl = "{{for x in items}}[{{x}}]{{end}}";
2411 let (out, spans) = render_with_spans(tpl, &b);
2412 assert_eq!(out, "[a][b][c]");
2413 let iter_spans: Vec<_> = spans
2414 .iter()
2415 .filter(|s| s.kind == PromptSpanKind::ForIteration)
2416 .collect();
2417 assert_eq!(iter_spans.len(), 3);
2418 let slices: Vec<&str> = iter_spans
2420 .iter()
2421 .map(|s| &out[s.output_start..s.output_end])
2422 .collect();
2423 assert_eq!(slices, ["[a]", "[b]", "[c]"]);
2424 }
2425
2426 #[test]
2427 fn provenance_preview_is_truncated() {
2428 let mut wrap = BTreeMap::new();
2431 wrap.insert("val".to_string(), s(&"x".repeat(500)));
2432 let b = dict(&[("blob", VmValue::Dict(Rc::new(wrap)))]);
2433 let (_, spans) = render_with_spans("{{blob.val}}", &b);
2434 let expr = spans
2435 .iter()
2436 .find(|s| s.kind == PromptSpanKind::Expr)
2437 .expect("expr span");
2438 let preview = expr.bound_value.as_deref().unwrap();
2439 assert!(preview.chars().count() <= 80, "preview too long: {preview}");
2440 assert!(preview.ends_with('…'));
2441 }
2442
2443 #[test]
2444 fn provenance_off_returns_empty_spans() {
2445 let b = dict(&[("x", s("y"))]);
2446 let (_, spans) =
2447 render_template_with_provenance("{{x}}", Some(&b), None, None, false).unwrap();
2448 assert!(spans.is_empty());
2449 }
2450
2451 #[test]
2452 fn bare_interp_missing_passthrough() {
2453 let b = dict(&[]);
2454 assert_eq!(render("hi {{name}}!", &b), "hi {{name}}!");
2455 }
2456
2457 #[test]
2458 fn legacy_if_truthy() {
2459 let b = dict(&[("x", VmValue::Bool(true))]);
2460 assert_eq!(render("{{if x}}yes{{end}}", &b), "yes");
2461 }
2462
2463 #[test]
2464 fn legacy_if_falsey() {
2465 let b = dict(&[("x", VmValue::Bool(false))]);
2466 assert_eq!(render("{{if x}}yes{{end}}", &b), "");
2467 }
2468
2469 #[test]
2470 fn if_else() {
2471 let b = dict(&[("x", VmValue::Bool(false))]);
2472 assert_eq!(render("{{if x}}A{{else}}B{{end}}", &b), "B");
2473 }
2474
2475 #[test]
2476 fn if_elif_else() {
2477 let b = dict(&[("n", VmValue::Int(2))]);
2478 let tpl = "{{if n == 1}}one{{elif n == 2}}two{{elif n == 3}}three{{else}}many{{end}}";
2479 assert_eq!(render(tpl, &b), "two");
2480 }
2481
2482 #[test]
2483 fn for_loop_basic() {
2484 let items = VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")]));
2485 let b = dict(&[("xs", items)]);
2486 assert_eq!(render("{{for x in xs}}{{x}},{{end}}", &b), "a,b,c,");
2487 }
2488
2489 #[test]
2490 fn for_loop_vars() {
2491 let items = VmValue::List(Rc::new(vec![s("a"), s("b")]));
2492 let b = dict(&[("xs", items)]);
2493 let tpl = "{{for x in xs}}{{loop.index}}:{{x}}{{if !loop.last}},{{end}}{{end}}";
2494 assert_eq!(render(tpl, &b), "1:a,2:b");
2495 }
2496
2497 #[test]
2498 fn for_empty_else() {
2499 let b = dict(&[("xs", VmValue::List(Rc::new(vec![])))]);
2500 assert_eq!(render("{{for x in xs}}A{{else}}empty{{end}}", &b), "empty");
2501 }
2502
2503 #[test]
2504 fn for_dict_kv() {
2505 let mut d: BTreeMap<String, VmValue> = BTreeMap::new();
2506 d.insert("a".into(), VmValue::Int(1));
2507 d.insert("b".into(), VmValue::Int(2));
2508 let b = dict(&[("m", VmValue::Dict(Rc::new(d)))]);
2509 assert_eq!(
2510 render("{{for k, v in m}}{{k}}={{v}};{{end}}", &b),
2511 "a=1;b=2;"
2512 );
2513 }
2514
2515 #[test]
2516 fn nested_path() {
2517 let mut inner: BTreeMap<String, VmValue> = BTreeMap::new();
2518 inner.insert("name".into(), s("Alice"));
2519 let b = dict(&[("user", VmValue::Dict(Rc::new(inner)))]);
2520 assert_eq!(render("{{user.name}}", &b), "Alice");
2521 }
2522
2523 #[test]
2524 fn list_index() {
2525 let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")])))]);
2526 assert_eq!(render("{{xs[1]}}", &b), "b");
2527 }
2528
2529 #[test]
2530 fn filter_upper() {
2531 let b = dict(&[("n", s("alice"))]);
2532 assert_eq!(render("{{n | upper}}", &b), "ALICE");
2533 }
2534
2535 #[test]
2536 fn filter_default() {
2537 let b = dict(&[("n", s(""))]);
2538 assert_eq!(render("{{n | default: \"anon\"}}", &b), "anon");
2539 }
2540
2541 #[test]
2542 fn filter_join() {
2543 let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b")])))]);
2544 assert_eq!(render("{{xs | join: \", \"}}", &b), "a, b");
2545 }
2546
2547 #[test]
2548 fn comparison_ops() {
2549 let b = dict(&[("n", VmValue::Int(5))]);
2550 assert_eq!(render("{{if n > 3}}big{{end}}", &b), "big");
2551 assert_eq!(render("{{if n >= 5 and n < 10}}ok{{end}}", &b), "ok");
2552 }
2553
2554 #[test]
2555 fn bool_not() {
2556 let b = dict(&[("x", VmValue::Bool(false))]);
2557 assert_eq!(render("{{if not x}}yes{{end}}", &b), "yes");
2558 assert_eq!(render("{{if !x}}yes{{end}}", &b), "yes");
2559 }
2560
2561 #[test]
2562 fn raw_block() {
2563 let b = dict(&[]);
2564 assert_eq!(
2565 render("A {{ raw }}{{not-a-directive}}{{ endraw }} B", &b),
2566 "A {{not-a-directive}} B"
2567 );
2568 }
2569
2570 #[test]
2571 fn comment_stripped() {
2572 let b = dict(&[("x", s("hi"))]);
2573 assert_eq!(render("A{{# hidden #}}B{{x}}", &b), "ABhi");
2574 }
2575
2576 #[test]
2577 fn whitespace_trim() {
2578 let b = dict(&[("x", s("v"))]);
2579 let tpl = "line1\n {{- x -}} \nline2";
2581 assert_eq!(render(tpl, &b), "line1vline2");
2582 }
2583
2584 #[test]
2585 fn filter_json() {
2586 let b = dict(&[(
2587 "x",
2588 VmValue::Dict(Rc::new({
2589 let mut m = BTreeMap::new();
2590 m.insert("a".into(), VmValue::Int(1));
2591 m
2592 })),
2593 )]);
2594 assert_eq!(render("{{x | json}}", &b), r#"{"a":1}"#);
2595 }
2596
2597 #[test]
2598 fn error_unterminated_if() {
2599 let b = dict(&[("x", VmValue::Bool(true))]);
2600 let r = render_template_result("{{if x}}open", Some(&b), None, None);
2601 assert!(r.is_err());
2602 }
2603
2604 #[test]
2605 fn error_unknown_filter() {
2606 let b = dict(&[("x", s("a"))]);
2607 let r = render_template_result("{{x | bogus}}", Some(&b), None, None);
2608 assert!(r.is_err());
2609 }
2610
2611 #[test]
2612 fn include_with() {
2613 use std::fs;
2614 let dir = tempdir();
2615 let partial = dir.join("p.prompt");
2616 fs::write(&partial, "[{{name}}]").unwrap();
2617 let parent = dir.join("main.prompt");
2618 fs::write(
2619 &parent,
2620 r#"hello {{ include "p.prompt" with { name: who } }}!"#,
2621 )
2622 .unwrap();
2623 let b = dict(&[("who", s("world"))]);
2624 let src = fs::read_to_string(&parent).unwrap();
2625 let out = render_template_result(&src, Some(&b), Some(&dir), Some(&parent)).unwrap();
2626 assert_eq!(out, "hello [world]!");
2627 }
2628
2629 #[test]
2630 fn prompt_render_indices_accumulate_in_order() {
2631 reset_prompt_registry();
2632 record_prompt_render_index("p-1", 5);
2633 record_prompt_render_index("p-1", 9);
2634 record_prompt_render_index("p-2", 7);
2635 let p1 = prompt_render_indices("p-1");
2636 assert_eq!(p1, vec![5, 9]);
2637 let p2 = prompt_render_indices("p-2");
2638 assert_eq!(p2, vec![7]);
2639 assert!(prompt_render_indices("unknown").is_empty());
2640 reset_prompt_registry();
2641 assert!(
2642 prompt_render_indices("p-1").is_empty(),
2643 "reset clears the map"
2644 );
2645 }
2646
2647 #[test]
2648 fn include_propagates_parent_span_chain() {
2649 use std::fs;
2650 let dir = tempdir();
2651 let leaf = dir.join("leaf.prompt");
2656 fs::write(&leaf, "LEAF:{{v}}").unwrap();
2657 let mid = dir.join("mid.prompt");
2658 fs::write(&mid, r#"MID:{{ include "leaf.prompt" }}"#).unwrap();
2659 let top = dir.join("top.prompt");
2660 fs::write(&top, r#"TOP:{{ include "mid.prompt" }}"#).unwrap();
2661 let b = dict(&[("v", s("ok"))]);
2662 let src = fs::read_to_string(&top).unwrap();
2663 let (rendered, spans) =
2664 render_template_with_provenance(&src, Some(&b), Some(&dir), Some(&top), true).unwrap();
2665 assert_eq!(rendered, "TOP:MID:LEAF:ok");
2666
2667 let leaf_expr = spans
2673 .iter()
2674 .find(|s| {
2675 matches!(
2676 s.kind,
2677 PromptSpanKind::Expr | PromptSpanKind::LegacyBareInterp
2678 ) && s.parent_span.is_some()
2679 })
2680 .expect("interpolation span emitted");
2681 let mid_parent = leaf_expr
2682 .parent_span
2683 .as_deref()
2684 .expect("leaf span must have mid's include as parent");
2685 assert_eq!(mid_parent.kind, PromptSpanKind::Include);
2686 let top_parent = mid_parent
2687 .parent_span
2688 .as_deref()
2689 .expect("mid's include must chain up to top's include");
2690 assert_eq!(top_parent.kind, PromptSpanKind::Include);
2691 assert!(top_parent.parent_span.is_none(), "chain bottoms out at top");
2692
2693 assert!(leaf_expr.template_uri.ends_with("leaf.prompt"));
2696 assert!(mid_parent.template_uri.ends_with("mid.prompt"));
2697 assert!(top_parent.template_uri.ends_with("top.prompt"));
2698 }
2699
2700 #[test]
2701 fn include_cycle_detected() {
2702 use std::fs;
2703 let dir = tempdir();
2704 let a = dir.join("a.prompt");
2705 let b = dir.join("b.prompt");
2706 fs::write(&a, r#"A{{ include "b.prompt" }}"#).unwrap();
2707 fs::write(&b, r#"B{{ include "a.prompt" }}"#).unwrap();
2708 let src = fs::read_to_string(&a).unwrap();
2709 let r = render_template_result(&src, None, Some(&dir), Some(&a));
2710 assert!(r.is_err());
2711 assert!(r.unwrap_err().kind.contains("circular include"));
2712 }
2713
2714 fn tempdir() -> PathBuf {
2715 let base = std::env::temp_dir().join(format!("harn-tpl-{}", nanoid()));
2716 std::fs::create_dir_all(&base).unwrap();
2717 base
2718 }
2719
2720 fn nanoid() -> String {
2721 use std::time::{SystemTime, UNIX_EPOCH};
2722 format!(
2723 "{}",
2724 SystemTime::now()
2725 .duration_since(UNIX_EPOCH)
2726 .unwrap()
2727 .as_nanos()
2728 )
2729 }
2730}