Skip to main content

harn_vm/stdlib/
template.rs

1//! Prompt-template engine for `.harn.prompt` assets and the `render` /
2//! `render_prompt` builtins.
3//!
4//! # Surface
5//!
6//! ```text
7//! {{ name }}                                 interpolation
8//! {{ user.name }} / {{ items[0] }}           nested path access
9//! {{ name | upper | default: "anon" }}       filter pipeline
10//! {{ if expr }}..{{ elif expr }}..{{ else }}..{{ end }}
11//! {{ for x in xs }}..{{ else }}..{{ end }}   else = empty-iterable fallback
12//! {{ for k, v in dict }}..{{ end }}
13//! {{ include "partial.harn.prompt" }}
14//! {{ include "partial.harn.prompt" with { x: name } }}
15//! {{# comment — stripped at parse time #}}
16//! {{ raw }}..literal {{braces}}..{{ endraw }}
17//! {{- x -}}                                  whitespace-trim markers
18//! ```
19//!
20//! Back-compat: bare `{{ident}}` resolves silently to the empty fallthrough
21//! (writes back the literal text on miss) — preserving the pre-v2 contract.
22//! All new constructs raise `TemplateError` on parse or evaluation failure.
23
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26use std::rc::Rc;
27
28use crate::value::{values_equal, VmError, VmValue};
29
30/// Parse-only validation for lint/preflight. Returns a human-readable error
31/// message when the template body is syntactically invalid; `Ok(())` when the
32/// template would parse. Does not resolve `{{ include }}` targets — those are
33/// validated at render time with their own error reporting.
34pub fn validate_template_syntax(src: &str) -> Result<(), String> {
35    parse(src).map(|_| ()).map_err(|e| e.message())
36}
37
38/// Full-featured entrypoint that preserves errors. `base` is the directory
39/// used to resolve `{{ include "..." }}` paths; `source_path` (if known) is
40/// included in error messages.
41pub(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// =========================================================================
70// Errors
71// =========================================================================
72
73#[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// =========================================================================
108// Tokenization (source → coarse token stream)
109// =========================================================================
110
111#[derive(Debug, Clone)]
112enum Token {
113    /// Literal text between directives.
114    Text {
115        content: String,
116        /// `{{-` on the following directive — trim trailing whitespace of this text.
117        trim_right: bool,
118        /// `-}}` on the preceding directive — trim leading whitespace of this text.
119        trim_left: bool,
120    },
121    /// Directive body (content between `{{` / `}}`, with `-` markers stripped).
122    Directive {
123        body: String,
124        line: usize,
125        col: usize,
126    },
127    /// Verbatim content of a `{{ raw }}..{{ endraw }}` block.
128    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        // Look for the next `{{`.
140        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            // Inspect the directive start for a `-` trim marker.
150            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        // Position after `{{` (and optional `-`).
168        let body_start = open + 2 + if this_trim_right { 1 } else { 0 };
169
170        // Handle `{{# comment #}}`: comments are stripped outright.
171        if body_start < len && bytes[body_start] == b'#' {
172            // Scan for `#}}` — allowing an optional `-` trim marker before `}}`.
173            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            // Comments do not consume trim markers that would otherwise apply —
180            // but we already consumed the leading `-`. Keep it simple: comments
181            // don't trim surrounding text.
182            continue;
183        }
184
185        // Handle `{{ raw }}` specially: capture until `{{ endraw }}` verbatim.
186        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            // Ensure "raw" is its own token; next char must be whitespace or `}}` or `-}}`.
190            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            // Find closing of this raw-open directive.
200            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            // Check trailing `-` on `}}`.
205            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; // Raw blocks don't honor whitespace trim.
208
209            // Scan for `{{ endraw }}` or `{{-endraw-}}`, whitespace-tolerant.
210            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        // Standard directive: scan for `}}`, respecting quoted strings so a
222        // `}}` inside `"..."` doesn't prematurely terminate.
223        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
268/// Scan forward from `start` looking for an unquoted `}}`. Returns
269/// `(offset_of_closing_braces, trim_marker_present)` where the trim marker
270/// is the `-` immediately before the `}}`.
271fn 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
304/// Find the matching `{{ endraw }}` (whitespace- and trim-marker-tolerant),
305/// returning `(directive_open_offset, directive_close_offset_exclusive)`.
306fn 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// =========================================================================
328// AST
329// =========================================================================
330
331#[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    /// A legacy bare `{{ident}}` that should silently pass-through its source
361    /// text on miss — preserves pre-v2 semantics for back-compat.
362    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
404// =========================================================================
405// Parser (token stream → AST)
406// =========================================================================
407
408fn 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        // Unclosed block — shouldn't reach here; parse_block returns on EOF.
417    }
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                    // Check for terminator tokens first — these are consumed by the caller.
462                    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            // Consume the terminator directive.
514            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                            // Consume `{{ end }}`.
529                            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        // Accept "x in expr" or "k, v in expr".
570        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)) // `k, v in dict` → value_var = v, key_var = k
583        } 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    // "<path-expr>" or "<path-expr> with { k: v, ... }"
638    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    // A single identifier with no dot/bracket/filter — used for back-compat
711    // silent pass-through.
712    is_ident(s)
713}
714
715fn trim_leading_line(s: &str) -> String {
716    // Strip whitespace up to and including the first newline.
717    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    // No trailing newline — strip leading spaces only.
732    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        // Remove this newline and the trailing whitespace.
743        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    // No newline boundary — strip trailing spaces only.
752    s[..i].to_string()
753}
754
755// ---- Expression parsing -------------------------------------------------
756
757fn 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                        // Only treat as float if followed by digit — otherwise it's a field access.
935                        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// =========================================================================
1150// Evaluation
1151// =========================================================================
1152
1153#[derive(Default, Debug, Clone)]
1154struct Scope<'a> {
1155    /// Root bindings passed by the caller.
1156    root: Option<&'a BTreeMap<String, VmValue>>,
1157    /// Override stack — pushed for `for`-loop variables and `include with`.
1158    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    /// Materialize a flat BTreeMap merging root + all overrides. Used when
1187    /// passing a fresh snapshot into an included partial.
1188    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                    // Silent pass-through: re-emit `{{ident}}` (no whitespace since we trimmed).
1239                    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            // Resolve relative to the including file's directory, falling back
1322            // to the asset-root resolver used by render(...).
1323            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            // Build child scope: flatten current + apply `with { }` overrides.
1366            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            // Short-circuit boolean ops.
1414            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(), // empty string — don't render "nil" literal
1560        other => other.display(),
1561    }
1562}
1563
1564// =========================================================================
1565// Filters
1566// =========================================================================
1567
1568fn 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
1801// =========================================================================
1802// Small helpers for token/expr splitting
1803// =========================================================================
1804
1805fn 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    // Match `kw` only at top level, outside strings and bracket groups.
1883    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// =========================================================================
1930// Tests
1931// =========================================================================
1932
1933#[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        // Trailing -}} eats newline after it; leading {{- eats newline before it.
2087        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}