use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use crate::value::{values_equal, VmError, VmValue};
pub fn validate_template_syntax(src: &str) -> Result<(), String> {
parse(src).map(|_| ()).map_err(|e| e.message())
}
pub(crate) fn render_template_result(
template: &str,
bindings: Option<&BTreeMap<String, VmValue>>,
base: Option<&Path>,
source_path: Option<&Path>,
) -> Result<String, TemplateError> {
let nodes = parse(template).map_err(|mut e| {
if let Some(p) = source_path {
e.path = Some(p.to_path_buf());
}
e
})?;
let mut out = String::with_capacity(template.len());
let mut scope = Scope::new(bindings);
let mut rc = RenderCtx {
base: base.map(Path::to_path_buf),
include_stack: Vec::new(),
current_path: source_path.map(Path::to_path_buf),
};
render_nodes(&nodes, &mut scope, &mut rc, &mut out).map_err(|mut e| {
if e.path.is_none() {
e.path = source_path.map(Path::to_path_buf);
}
e
})?;
Ok(out)
}
#[derive(Debug, Clone)]
pub(crate) struct TemplateError {
pub path: Option<PathBuf>,
pub line: usize,
pub col: usize,
pub kind: String,
}
impl TemplateError {
fn new(line: usize, col: usize, msg: impl Into<String>) -> Self {
Self {
path: None,
line,
col,
kind: msg.into(),
}
}
pub(crate) fn message(&self) -> String {
let p = self
.path
.as_ref()
.map(|p| format!("{} ", p.display()))
.unwrap_or_default();
format!("{}at {}:{}: {}", p, self.line, self.col, self.kind)
}
}
impl From<TemplateError> for VmError {
fn from(e: TemplateError) -> Self {
VmError::Thrown(VmValue::String(Rc::from(e.message())))
}
}
#[derive(Debug, Clone)]
enum Token {
Text {
content: String,
trim_right: bool,
trim_left: bool,
},
Directive {
body: String,
line: usize,
col: usize,
},
Raw(String),
}
fn tokenize(src: &str) -> Result<Vec<Token>, TemplateError> {
let bytes = src.as_bytes();
let mut tokens: Vec<Token> = Vec::new();
let mut cursor = 0;
let mut pending_trim_left = false;
let len = bytes.len();
while cursor < len {
let open = find_from(src, cursor, "{{");
let text_end = open.unwrap_or(len);
let raw_text = &src[cursor..text_end];
let this_trim_left = pending_trim_left;
pending_trim_left = false;
let mut this_trim_right = false;
if let Some(o) = open {
if o + 2 < len && bytes[o + 2] == b'-' {
this_trim_right = true;
}
}
if !raw_text.is_empty() || this_trim_left || this_trim_right {
tokens.push(Token::Text {
content: raw_text.to_string(),
trim_right: this_trim_right,
trim_left: this_trim_left,
});
}
let Some(open) = open else {
break;
};
let body_start = open + 2 + if this_trim_right { 1 } else { 0 };
if body_start < len && bytes[body_start] == b'#' {
let after_hash = body_start + 1;
let Some(close_hash) = find_from(src, after_hash, "#}}") else {
let (line, col) = line_col(src, open);
return Err(TemplateError::new(line, col, "unterminated comment"));
};
cursor = close_hash + 3;
continue;
}
let body_trim_start = skip_ws(src, body_start);
let raw_kw_end = body_trim_start + 3;
if raw_kw_end <= len && &src[body_trim_start..raw_kw_end.min(len)] == "raw" && {
let after = raw_kw_end;
after >= len
|| bytes[after] == b' '
|| bytes[after] == b'\t'
|| bytes[after] == b'\n'
|| bytes[after] == b'\r'
|| (after + 1 < len && &src[after..after + 2] == "}}")
|| (after + 2 < len && &src[after..after + 3] == "-}}")
} {
let Some(dir_close) = find_from(src, raw_kw_end, "}}") else {
let (line, col) = line_col(src, open);
return Err(TemplateError::new(line, col, "unterminated directive"));
};
let raw_body_start = dir_close + 2;
let trim_after_open = dir_close > 0 && bytes[dir_close - 1] == b'-';
let _ = trim_after_open;
let (raw_end_open, raw_end_close) =
find_endraw(src, raw_body_start).ok_or_else(|| {
let (line, col) = line_col(src, open);
TemplateError::new(line, col, "unterminated `{{ raw }}` block")
})?;
let raw_content = src[raw_body_start..raw_end_open].to_string();
tokens.push(Token::Raw(raw_content));
cursor = raw_end_close;
continue;
}
let (close_pos, trim_after) = find_directive_close(src, body_start).ok_or_else(|| {
let (line, col) = line_col(src, open);
TemplateError::new(line, col, "unterminated directive")
})?;
let body_end = if trim_after { close_pos - 1 } else { close_pos };
let body = src[body_start..body_end].trim().to_string();
let (line, col) = line_col(src, open);
tokens.push(Token::Directive { body, line, col });
cursor = close_pos + 2;
pending_trim_left = trim_after;
}
Ok(tokens)
}
fn find_from(s: &str, from: usize, pat: &str) -> Option<usize> {
s[from..].find(pat).map(|i| i + from)
}
fn skip_ws(s: &str, from: usize) -> usize {
let bytes = s.as_bytes();
let mut i = from;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
i
}
fn line_col(s: &str, offset: usize) -> (usize, usize) {
let mut line = 1usize;
let mut col = 1usize;
for (i, ch) in s.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
fn find_directive_close(s: &str, start: usize) -> Option<(usize, bool)> {
let bytes = s.as_bytes();
let mut i = start;
let mut in_str = false;
let mut str_quote = b'"';
while i + 1 < bytes.len() {
let b = bytes[i];
if in_str {
if b == b'\\' {
i += 2;
continue;
}
if b == str_quote {
in_str = false;
}
i += 1;
continue;
}
if b == b'"' || b == b'\'' {
in_str = true;
str_quote = b;
i += 1;
continue;
}
if b == b'}' && bytes[i + 1] == b'}' {
let trim = i > start && bytes[i - 1] == b'-';
return Some((i, trim));
}
i += 1;
}
None
}
fn find_endraw(s: &str, from: usize) -> Option<(usize, usize)> {
let mut cursor = from;
while let Some(open) = find_from(s, cursor, "{{") {
let after = open + 2;
let body_start = if s.as_bytes().get(after) == Some(&b'-') {
after + 1
} else {
after
};
let body_trim_start = skip_ws(s, body_start);
let close = find_directive_close(s, body_start)?;
let body_end = if close.1 { close.0 - 1 } else { close.0 };
let body = s[body_trim_start..body_end].trim();
if body == "endraw" {
return Some((open, close.0 + 2));
}
cursor = close.0 + 2;
}
None
}
#[derive(Debug, Clone)]
enum Node {
Text(String),
Expr {
expr: Expr,
line: usize,
col: usize,
},
If {
branches: Vec<(Expr, Vec<Node>)>,
else_branch: Option<Vec<Node>>,
line: usize,
col: usize,
},
For {
value_var: String,
key_var: Option<String>,
iter: Expr,
body: Vec<Node>,
empty: Option<Vec<Node>>,
line: usize,
col: usize,
},
Include {
path: Expr,
with: Option<Vec<(String, Expr)>>,
line: usize,
col: usize,
},
LegacyBareInterp {
ident: String,
},
}
#[derive(Debug, Clone)]
enum Expr {
Nil,
Bool(bool),
Int(i64),
Float(f64),
Str(String),
Path(Vec<PathSeg>),
Unary(UnOp, Box<Expr>),
Binary(BinOp, Box<Expr>, Box<Expr>),
Filter(Box<Expr>, String, Vec<Expr>),
}
#[derive(Debug, Clone)]
enum PathSeg {
Field(String),
Index(i64),
Key(String),
}
#[derive(Debug, Clone, Copy)]
enum UnOp {
Not,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum BinOp {
Eq,
Neq,
Lt,
Le,
Gt,
Ge,
And,
Or,
}
fn parse(src: &str) -> Result<Vec<Node>, TemplateError> {
let tokens = tokenize(src)?;
let mut p = Parser {
tokens: &tokens,
pos: 0,
};
let nodes = p.parse_block(&[])?;
if p.pos < tokens.len() {
}
Ok(nodes)
}
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
}
impl<'a> Parser<'a> {
fn peek(&self) -> Option<&'a Token> {
self.tokens.get(self.pos)
}
fn parse_block(&mut self, stops: &[&str]) -> Result<Vec<Node>, TemplateError> {
let mut out = Vec::new();
while let Some(tok) = self.peek() {
match tok {
Token::Text {
content,
trim_right,
trim_left,
} => {
let mut s = content.clone();
if *trim_left {
s = trim_leading_line(&s);
}
if *trim_right {
s = trim_trailing_line(&s);
}
if !s.is_empty() {
out.push(Node::Text(s));
}
self.pos += 1;
}
Token::Raw(content) => {
if !content.is_empty() {
out.push(Node::Text(content.clone()));
}
self.pos += 1;
}
Token::Directive { body, line, col } => {
let (line, col) = (*line, *col);
let body = body.clone();
let first_word = first_word(&body);
if stops.contains(&first_word) {
return Ok(out);
}
self.pos += 1;
if body == "end" {
return Err(TemplateError::new(line, col, "unexpected `{{ end }}`"));
}
if body == "else" {
return Err(TemplateError::new(line, col, "unexpected `{{ else }}`"));
}
if first_word == "elif" {
return Err(TemplateError::new(line, col, "unexpected `{{ elif }}`"));
}
if first_word == "if" {
let cond_src = body[2..].trim();
let cond = parse_expr(cond_src, line, col)?;
let node = self.parse_if(cond, line, col)?;
out.push(node);
} else if first_word == "for" {
let node = self.parse_for(body[3..].trim(), line, col)?;
out.push(node);
} else if first_word == "include" {
let node = parse_include(body[7..].trim(), line, col)?;
out.push(node);
} else if is_bare_ident(&body) {
out.push(Node::LegacyBareInterp { ident: body });
} else {
let expr = parse_expr(&body, line, col)?;
out.push(Node::Expr { expr, line, col });
}
}
}
}
Ok(out)
}
fn parse_if(
&mut self,
first_cond: Expr,
line: usize,
col: usize,
) -> Result<Node, TemplateError> {
let mut branches = Vec::new();
let mut else_branch = None;
let mut cur_cond = first_cond;
loop {
let body = self.parse_block(&["end", "else", "elif"])?;
branches.push((cur_cond, body));
let tok = self.peek().cloned();
match tok {
Some(Token::Directive {
body: tbody,
line: tline,
col: tcol,
}) => {
let fw = first_word(&tbody);
self.pos += 1;
match fw {
"end" => break,
"else" => {
let eb = self.parse_block(&["end"])?;
else_branch = Some(eb);
match self.peek() {
Some(Token::Directive { body, .. }) if body == "end" => {
self.pos += 1;
}
_ => {
return Err(TemplateError::new(
tline,
tcol,
"`{{ else }}` missing matching `{{ end }}`",
));
}
}
break;
}
"elif" => {
let cond = parse_expr(tbody[4..].trim(), tline, tcol)?;
cur_cond = cond;
continue;
}
_ => unreachable!(),
}
}
_ => {
return Err(TemplateError::new(
line,
col,
"`{{ if }}` missing matching `{{ end }}`",
));
}
}
}
Ok(Node::If {
branches,
else_branch,
line,
col,
})
}
fn parse_for(&mut self, spec: &str, line: usize, col: usize) -> Result<Node, TemplateError> {
let (head, iter_src) = match split_once_keyword(spec, " in ") {
Some(p) => p,
None => return Err(TemplateError::new(line, col, "expected `in` in for-loop")),
};
let head = head.trim();
let iter_src = iter_src.trim();
let (value_var, key_var) = if let Some((a, b)) = head.split_once(',') {
let a = a.trim().to_string();
let b = b.trim().to_string();
if !is_ident(&a) || !is_ident(&b) {
return Err(TemplateError::new(line, col, "invalid for-loop variables"));
}
(b, Some(a)) } else {
if !is_ident(head) {
return Err(TemplateError::new(line, col, "invalid for-loop variable"));
}
(head.to_string(), None)
};
let iter = parse_expr(iter_src, line, col)?;
let body = self.parse_block(&["end", "else"])?;
let (empty, _) = match self.peek().cloned() {
Some(Token::Directive { body: tbody, .. }) => {
let fw = first_word(&tbody);
self.pos += 1;
if fw == "end" {
(None, ())
} else if fw == "else" {
let empty_body = self.parse_block(&["end"])?;
match self.peek() {
Some(Token::Directive { body, .. }) if body == "end" => {
self.pos += 1;
}
_ => {
return Err(TemplateError::new(
line,
col,
"`{{ else }}` missing matching `{{ end }}`",
));
}
}
(Some(empty_body), ())
} else {
unreachable!()
}
}
_ => {
return Err(TemplateError::new(
line,
col,
"`{{ for }}` missing matching `{{ end }}`",
));
}
};
Ok(Node::For {
value_var,
key_var,
iter,
body,
empty,
line,
col,
})
}
}
fn parse_include(spec: &str, line: usize, col: usize) -> Result<Node, TemplateError> {
let (path_src, with_src) = match split_once_keyword(spec, " with ") {
Some((a, b)) => (a.trim(), Some(b.trim())),
None => (spec.trim(), None),
};
let path = parse_expr(path_src, line, col)?;
let with = if let Some(src) = with_src {
Some(parse_dict_literal(src, line, col)?)
} else {
None
};
Ok(Node::Include {
path,
with,
line,
col,
})
}
fn parse_dict_literal(
src: &str,
line: usize,
col: usize,
) -> Result<Vec<(String, Expr)>, TemplateError> {
let s = src.trim();
if !s.starts_with('{') || !s.ends_with('}') {
return Err(TemplateError::new(
line,
col,
"expected `{ ... }` after `with`",
));
}
let inner = &s[1..s.len() - 1];
let mut pairs = Vec::new();
for chunk in split_top_level(inner, ',') {
let chunk = chunk.trim();
if chunk.is_empty() {
continue;
}
let (k, v) = match split_once_top_level(chunk, ':') {
Some(p) => p,
None => {
return Err(TemplateError::new(
line,
col,
"expected `key: value` in include bindings",
));
}
};
let k = k.trim();
if !is_ident(k) {
return Err(TemplateError::new(line, col, "invalid include binding key"));
}
let v = parse_expr(v.trim(), line, col)?;
pairs.push((k.to_string(), v));
}
Ok(pairs)
}
fn first_word(s: &str) -> &str {
s.split(|c: char| c.is_whitespace()).next().unwrap_or("")
}
fn is_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
fn is_bare_ident(s: &str) -> bool {
is_ident(s)
}
fn trim_leading_line(s: &str) -> String {
let mut i = 0;
let bytes = s.as_bytes();
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i < bytes.len() && bytes[i] == b'\n' {
return s[i + 1..].to_string();
}
if i < bytes.len() && bytes[i] == b'\r' {
if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
return s[i + 2..].to_string();
}
return s[i + 1..].to_string();
}
s[i..].to_string()
}
fn trim_trailing_line(s: &str) -> String {
let bytes = s.as_bytes();
let mut i = bytes.len();
while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
i -= 1;
}
if i > 0 && bytes[i - 1] == b'\n' {
let end = i - 1;
let end = if end > 0 && bytes[end - 1] == b'\r' {
end - 1
} else {
end
};
return s[..end].to_string();
}
s[..i].to_string()
}
fn parse_expr(src: &str, line: usize, col: usize) -> Result<Expr, TemplateError> {
let tokens = tokenize_expr(src, line, col)?;
let mut p = ExprParser {
toks: &tokens,
pos: 0,
line,
col,
};
let e = p.parse_filter()?;
if p.pos < tokens.len() {
return Err(TemplateError::new(
line,
col,
format!("unexpected token `{:?}` in expression", p.toks[p.pos]),
));
}
Ok(e)
}
#[derive(Debug, Clone, PartialEq)]
enum EToken {
Ident(String),
Str(String),
Int(i64),
Float(f64),
LParen,
RParen,
LBracket,
RBracket,
Dot,
Comma,
Colon,
Pipe,
Bang,
EqEq,
BangEq,
Lt,
Le,
Gt,
Ge,
AndKw,
OrKw,
NotKw,
True,
False,
Nil,
}
fn tokenize_expr(src: &str, line: usize, col: usize) -> Result<Vec<EToken>, TemplateError> {
let bytes = src.as_bytes();
let mut toks = Vec::new();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_whitespace() {
i += 1;
continue;
}
match b {
b'(' => {
toks.push(EToken::LParen);
i += 1;
}
b')' => {
toks.push(EToken::RParen);
i += 1;
}
b'[' => {
toks.push(EToken::LBracket);
i += 1;
}
b']' => {
toks.push(EToken::RBracket);
i += 1;
}
b'.' => {
toks.push(EToken::Dot);
i += 1;
}
b',' => {
toks.push(EToken::Comma);
i += 1;
}
b':' => {
toks.push(EToken::Colon);
i += 1;
}
b'|' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'|' {
toks.push(EToken::OrKw);
i += 2;
} else {
toks.push(EToken::Pipe);
i += 1;
}
}
b'&' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'&' {
toks.push(EToken::AndKw);
i += 2;
} else {
return Err(TemplateError::new(line, col, "unexpected `&`"));
}
}
b'!' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
toks.push(EToken::BangEq);
i += 2;
} else {
toks.push(EToken::Bang);
i += 1;
}
}
b'=' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
toks.push(EToken::EqEq);
i += 2;
} else {
return Err(TemplateError::new(line, col, "unexpected `=` (use `==`)"));
}
}
b'<' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
toks.push(EToken::Le);
i += 2;
} else {
toks.push(EToken::Lt);
i += 1;
}
}
b'>' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
toks.push(EToken::Ge);
i += 2;
} else {
toks.push(EToken::Gt);
i += 1;
}
}
b'"' | b'\'' => {
let quote = b;
let start = i + 1;
let mut j = start;
let mut out = String::new();
while j < bytes.len() && bytes[j] != quote {
if bytes[j] == b'\\' && j + 1 < bytes.len() {
match bytes[j + 1] {
b'n' => out.push('\n'),
b't' => out.push('\t'),
b'r' => out.push('\r'),
b'\\' => out.push('\\'),
b'"' => out.push('"'),
b'\'' => out.push('\''),
c => out.push(c as char),
}
j += 2;
continue;
}
out.push(bytes[j] as char);
j += 1;
}
if j >= bytes.len() {
return Err(TemplateError::new(line, col, "unterminated string literal"));
}
toks.push(EToken::Str(out));
i = j + 1;
}
b'0'..=b'9' | b'-'
if b != b'-' || (i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) =>
{
let start = i;
if bytes[i] == b'-' {
i += 1;
}
let mut is_float = false;
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
if bytes[i] == b'.' {
if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
is_float = true;
i += 1;
continue;
} else {
break;
}
}
i += 1;
}
let lex = &src[start..i];
if is_float {
let v: f64 = lex.parse().map_err(|_| {
TemplateError::new(line, col, format!("invalid number `{lex}`"))
})?;
toks.push(EToken::Float(v));
} else {
let v: i64 = lex.parse().map_err(|_| {
TemplateError::new(line, col, format!("invalid integer `{lex}`"))
})?;
toks.push(EToken::Int(v));
}
}
c if c.is_ascii_alphabetic() || c == b'_' => {
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
let word = &src[start..i];
match word {
"true" => toks.push(EToken::True),
"false" => toks.push(EToken::False),
"nil" => toks.push(EToken::Nil),
"and" => toks.push(EToken::AndKw),
"or" => toks.push(EToken::OrKw),
"not" => toks.push(EToken::NotKw),
other => toks.push(EToken::Ident(other.to_string())),
}
}
_ => {
return Err(TemplateError::new(
line,
col,
format!("unexpected character `{}` in expression", b as char),
));
}
}
}
Ok(toks)
}
struct ExprParser<'a> {
toks: &'a [EToken],
pos: usize,
line: usize,
col: usize,
}
impl<'a> ExprParser<'a> {
fn peek(&self) -> Option<&EToken> {
self.toks.get(self.pos)
}
fn eat(&mut self, t: &EToken) -> bool {
if self.peek() == Some(t) {
self.pos += 1;
true
} else {
false
}
}
fn err(&self, m: impl Into<String>) -> TemplateError {
TemplateError::new(self.line, self.col, m)
}
fn parse_filter(&mut self) -> Result<Expr, TemplateError> {
let mut left = self.parse_or()?;
while self.eat(&EToken::Pipe) {
let name = match self.peek() {
Some(EToken::Ident(n)) => n.clone(),
_ => return Err(self.err("expected filter name after `|`")),
};
self.pos += 1;
let mut args = Vec::new();
if self.eat(&EToken::Colon) {
loop {
let a = self.parse_or()?;
args.push(a);
if !self.eat(&EToken::Comma) {
break;
}
}
}
left = Expr::Filter(Box::new(left), name, args);
}
Ok(left)
}
fn parse_or(&mut self) -> Result<Expr, TemplateError> {
let mut left = self.parse_and()?;
while self.eat(&EToken::OrKw) {
let right = self.parse_and()?;
left = Expr::Binary(BinOp::Or, Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_and(&mut self) -> Result<Expr, TemplateError> {
let mut left = self.parse_not()?;
while self.eat(&EToken::AndKw) {
let right = self.parse_not()?;
left = Expr::Binary(BinOp::And, Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_not(&mut self) -> Result<Expr, TemplateError> {
if self.eat(&EToken::Bang) || self.eat(&EToken::NotKw) {
let inner = self.parse_not()?;
return Ok(Expr::Unary(UnOp::Not, Box::new(inner)));
}
self.parse_cmp()
}
fn parse_cmp(&mut self) -> Result<Expr, TemplateError> {
let left = self.parse_unary()?;
let op = match self.peek() {
Some(EToken::EqEq) => Some(BinOp::Eq),
Some(EToken::BangEq) => Some(BinOp::Neq),
Some(EToken::Lt) => Some(BinOp::Lt),
Some(EToken::Le) => Some(BinOp::Le),
Some(EToken::Gt) => Some(BinOp::Gt),
Some(EToken::Ge) => Some(BinOp::Ge),
_ => None,
};
if let Some(op) = op {
self.pos += 1;
let right = self.parse_unary()?;
return Ok(Expr::Binary(op, Box::new(left), Box::new(right)));
}
Ok(left)
}
fn parse_unary(&mut self) -> Result<Expr, TemplateError> {
self.parse_primary()
}
fn parse_primary(&mut self) -> Result<Expr, TemplateError> {
let tok = self
.peek()
.cloned()
.ok_or_else(|| self.err("expected expression"))?;
self.pos += 1;
let base = match tok {
EToken::Nil => Expr::Nil,
EToken::True => Expr::Bool(true),
EToken::False => Expr::Bool(false),
EToken::Int(n) => Expr::Int(n),
EToken::Float(f) => Expr::Float(f),
EToken::Str(s) => Expr::Str(s),
EToken::LParen => {
let e = self.parse_or()?;
if !self.eat(&EToken::RParen) {
return Err(self.err("expected `)`"));
}
e
}
EToken::Ident(name) => self.parse_path(name)?,
EToken::Bang | EToken::NotKw => {
let inner = self.parse_primary()?;
Expr::Unary(UnOp::Not, Box::new(inner))
}
other => return Err(self.err(format!("unexpected token `{:?}`", other))),
};
Ok(base)
}
fn parse_path(&mut self, head: String) -> Result<Expr, TemplateError> {
let mut segs = vec![PathSeg::Field(head)];
loop {
match self.peek() {
Some(EToken::Dot) => {
self.pos += 1;
match self.peek().cloned() {
Some(EToken::Ident(n)) => {
self.pos += 1;
segs.push(PathSeg::Field(n));
}
_ => return Err(self.err("expected identifier after `.`")),
}
}
Some(EToken::LBracket) => {
self.pos += 1;
match self.peek().cloned() {
Some(EToken::Int(n)) => {
self.pos += 1;
segs.push(PathSeg::Index(n));
}
Some(EToken::Str(s)) => {
self.pos += 1;
segs.push(PathSeg::Key(s));
}
_ => return Err(self.err("expected integer or string inside `[...]`")),
}
if !self.eat(&EToken::RBracket) {
return Err(self.err("expected `]`"));
}
}
_ => break,
}
}
Ok(Expr::Path(segs))
}
}
#[derive(Default, Debug, Clone)]
struct Scope<'a> {
root: Option<&'a BTreeMap<String, VmValue>>,
overrides: Vec<BTreeMap<String, VmValue>>,
}
impl<'a> Scope<'a> {
fn new(root: Option<&'a BTreeMap<String, VmValue>>) -> Self {
Self {
root,
overrides: Vec::new(),
}
}
fn lookup(&self, name: &str) -> Option<VmValue> {
for layer in self.overrides.iter().rev() {
if let Some(v) = layer.get(name) {
return Some(v.clone());
}
}
self.root.and_then(|m| m.get(name)).cloned()
}
fn push(&mut self, layer: BTreeMap<String, VmValue>) {
self.overrides.push(layer);
}
fn pop(&mut self) {
self.overrides.pop();
}
fn flatten(&self) -> BTreeMap<String, VmValue> {
let mut out = BTreeMap::new();
if let Some(r) = self.root {
for (k, v) in r.iter() {
out.insert(k.clone(), v.clone());
}
}
for layer in &self.overrides {
for (k, v) in layer {
out.insert(k.clone(), v.clone());
}
}
out
}
}
struct RenderCtx {
base: Option<PathBuf>,
include_stack: Vec<PathBuf>,
current_path: Option<PathBuf>,
}
fn render_nodes(
nodes: &[Node],
scope: &mut Scope<'_>,
rc: &mut RenderCtx,
out: &mut String,
) -> Result<(), TemplateError> {
for n in nodes {
render_node(n, scope, rc, out)?;
}
Ok(())
}
fn render_node(
node: &Node,
scope: &mut Scope<'_>,
rc: &mut RenderCtx,
out: &mut String,
) -> Result<(), TemplateError> {
match node {
Node::Text(s) => out.push_str(s),
Node::Expr { expr, line, col } => {
let v = eval_expr(expr, scope, *line, *col)?;
out.push_str(&display_value(&v));
}
Node::LegacyBareInterp { ident } => {
match scope.lookup(ident) {
Some(v) => out.push_str(&display_value(&v)),
None => {
out.push_str("{{");
out.push_str(ident);
out.push_str("}}");
}
}
}
Node::If {
branches,
else_branch,
line,
col,
} => {
let mut matched = false;
for (cond, body) in branches {
let v = eval_expr(cond, scope, *line, *col)?;
if truthy(&v) {
render_nodes(body, scope, rc, out)?;
matched = true;
break;
}
}
if !matched {
if let Some(eb) = else_branch {
render_nodes(eb, scope, rc, out)?;
}
}
}
Node::For {
value_var,
key_var,
iter,
body,
empty,
line,
col,
} => {
let v = eval_expr(iter, scope, *line, *col)?;
let items: Vec<(VmValue, VmValue)> =
iterable_items(&v).map_err(|m| TemplateError::new(*line, *col, m))?;
if items.is_empty() {
if let Some(eb) = empty {
render_nodes(eb, scope, rc, out)?;
}
} else {
let length = items.len() as i64;
for (idx, (k, val)) in items.iter().enumerate() {
let mut layer: BTreeMap<String, VmValue> = BTreeMap::new();
layer.insert(value_var.clone(), val.clone());
if let Some(kv) = key_var {
layer.insert(kv.clone(), k.clone());
}
let mut loop_map: BTreeMap<String, VmValue> = BTreeMap::new();
loop_map.insert("index".into(), VmValue::Int(idx as i64 + 1));
loop_map.insert("index0".into(), VmValue::Int(idx as i64));
loop_map.insert("first".into(), VmValue::Bool(idx == 0));
loop_map.insert("last".into(), VmValue::Bool(idx as i64 == length - 1));
loop_map.insert("length".into(), VmValue::Int(length));
layer.insert("loop".into(), VmValue::Dict(Rc::new(loop_map)));
scope.push(layer);
let res = render_nodes(body, scope, rc, out);
scope.pop();
res?;
}
}
}
Node::Include {
path,
with,
line,
col,
} => {
let path_val = eval_expr(path, scope, *line, *col)?;
let path_str = match path_val {
VmValue::String(s) => s.to_string(),
other => {
return Err(TemplateError::new(
*line,
*col,
format!("include path must be a string (got {})", other.type_name()),
))
}
};
let resolved: PathBuf = if Path::new(&path_str).is_absolute() {
PathBuf::from(&path_str)
} else if let Some(base) = &rc.base {
base.join(&path_str)
} else {
crate::stdlib::process::resolve_source_asset_path(&path_str)
};
let canonical = resolved.canonicalize().unwrap_or(resolved.clone());
if rc.include_stack.iter().any(|p| p == &canonical) {
let chain = rc
.include_stack
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(" → ");
return Err(TemplateError::new(
*line,
*col,
format!(
"circular include detected: {chain} → {}",
canonical.display()
),
));
}
if rc.include_stack.len() > 32 {
return Err(TemplateError::new(
*line,
*col,
"include depth exceeded (32 levels)",
));
}
let contents = std::fs::read_to_string(&resolved).map_err(|e| {
TemplateError::new(
*line,
*col,
format!(
"failed to read included template {}: {e}",
resolved.display()
),
)
})?;
let new_base = resolved.parent().map(Path::to_path_buf);
let mut child_bindings = scope.flatten();
if let Some(pairs) = with {
for (k, e) in pairs {
let v = eval_expr(e, scope, *line, *col)?;
child_bindings.insert(k.clone(), v);
}
}
let child_nodes = parse(&contents).map_err(|mut e| {
if e.path.is_none() {
e.path = Some(resolved.clone());
}
e
})?;
let mut child_scope = Scope::new(Some(&child_bindings));
let saved_base = rc.base.clone();
let saved_current = rc.current_path.clone();
rc.base = new_base;
rc.current_path = Some(resolved.clone());
rc.include_stack.push(canonical);
let res = render_nodes(&child_nodes, &mut child_scope, rc, out);
rc.include_stack.pop();
rc.base = saved_base;
rc.current_path = saved_current;
res?;
}
}
Ok(())
}
fn eval_expr(
expr: &Expr,
scope: &Scope<'_>,
line: usize,
col: usize,
) -> Result<VmValue, TemplateError> {
match expr {
Expr::Nil => Ok(VmValue::Nil),
Expr::Bool(b) => Ok(VmValue::Bool(*b)),
Expr::Int(n) => Ok(VmValue::Int(*n)),
Expr::Float(f) => Ok(VmValue::Float(*f)),
Expr::Str(s) => Ok(VmValue::String(Rc::from(s.as_str()))),
Expr::Path(segs) => Ok(resolve_path(segs, scope)),
Expr::Unary(UnOp::Not, inner) => {
let v = eval_expr(inner, scope, line, col)?;
Ok(VmValue::Bool(!truthy(&v)))
}
Expr::Binary(op, a, b) => {
match op {
BinOp::And => {
let av = eval_expr(a, scope, line, col)?;
if !truthy(&av) {
return Ok(av);
}
return eval_expr(b, scope, line, col);
}
BinOp::Or => {
let av = eval_expr(a, scope, line, col)?;
if truthy(&av) {
return Ok(av);
}
return eval_expr(b, scope, line, col);
}
_ => {}
}
let av = eval_expr(a, scope, line, col)?;
let bv = eval_expr(b, scope, line, col)?;
Ok(apply_cmp(*op, &av, &bv))
}
Expr::Filter(inner, name, args) => {
let v = eval_expr(inner, scope, line, col)?;
let arg_vals = args
.iter()
.map(|e| eval_expr(e, scope, line, col))
.collect::<Result<Vec<_>, _>>()?;
apply_filter(name, &v, &arg_vals, line, col)
}
}
}
fn resolve_path(segs: &[PathSeg], scope: &Scope<'_>) -> VmValue {
let mut cur: VmValue = match segs.first() {
Some(PathSeg::Field(n)) => match scope.lookup(n) {
Some(v) => v,
None => return VmValue::Nil,
},
_ => return VmValue::Nil,
};
for seg in &segs[1..] {
cur = match (seg, &cur) {
(PathSeg::Field(n), VmValue::Dict(d)) => d.get(n).cloned().unwrap_or(VmValue::Nil),
(PathSeg::Key(k), VmValue::Dict(d)) => d.get(k).cloned().unwrap_or(VmValue::Nil),
(PathSeg::Index(i), VmValue::List(items)) => {
let idx = if *i < 0 { items.len() as i64 + *i } else { *i };
if idx < 0 || (idx as usize) >= items.len() {
VmValue::Nil
} else {
items[idx as usize].clone()
}
}
(PathSeg::Index(i), VmValue::String(s)) => {
let chars: Vec<char> = s.chars().collect();
let idx = if *i < 0 { chars.len() as i64 + *i } else { *i };
if idx < 0 || (idx as usize) >= chars.len() {
VmValue::Nil
} else {
VmValue::String(Rc::from(chars[idx as usize].to_string()))
}
}
_ => VmValue::Nil,
};
}
cur
}
fn truthy(v: &VmValue) -> bool {
match v {
VmValue::Nil => false,
VmValue::Bool(b) => *b,
VmValue::Int(n) => *n != 0,
VmValue::Float(f) => *f != 0.0,
VmValue::String(s) => !s.trim().is_empty(),
VmValue::List(items) => !items.is_empty(),
VmValue::Dict(d) => !d.is_empty(),
_ => true,
}
}
fn apply_cmp(op: BinOp, a: &VmValue, b: &VmValue) -> VmValue {
match op {
BinOp::Eq => VmValue::Bool(values_equal(a, b)),
BinOp::Neq => VmValue::Bool(!values_equal(a, b)),
BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => {
let ord = compare(a, b);
match (op, ord) {
(BinOp::Lt, Some(o)) => VmValue::Bool(o == std::cmp::Ordering::Less),
(BinOp::Le, Some(o)) => VmValue::Bool(o != std::cmp::Ordering::Greater),
(BinOp::Gt, Some(o)) => VmValue::Bool(o == std::cmp::Ordering::Greater),
(BinOp::Ge, Some(o)) => VmValue::Bool(o != std::cmp::Ordering::Less),
_ => VmValue::Bool(false),
}
}
BinOp::And | BinOp::Or => unreachable!(),
}
}
fn compare(a: &VmValue, b: &VmValue) -> Option<std::cmp::Ordering> {
match (a, b) {
(VmValue::Int(x), VmValue::Int(y)) => Some(x.cmp(y)),
(VmValue::Float(x), VmValue::Float(y)) => x.partial_cmp(y),
(VmValue::Int(x), VmValue::Float(y)) => (*x as f64).partial_cmp(y),
(VmValue::Float(x), VmValue::Int(y)) => x.partial_cmp(&(*y as f64)),
(VmValue::String(x), VmValue::String(y)) => Some(x.as_ref().cmp(y.as_ref())),
_ => None,
}
}
fn iterable_items(v: &VmValue) -> Result<Vec<(VmValue, VmValue)>, String> {
match v {
VmValue::List(items) => Ok(items
.iter()
.enumerate()
.map(|(i, it)| (VmValue::Int(i as i64), it.clone()))
.collect()),
VmValue::Dict(d) => Ok(d
.iter()
.map(|(k, v)| (VmValue::String(Rc::from(k.as_str())), v.clone()))
.collect()),
VmValue::Set(items) => Ok(items
.iter()
.enumerate()
.map(|(i, it)| (VmValue::Int(i as i64), it.clone()))
.collect()),
VmValue::Range(r) => {
let mut out = Vec::new();
let len = r.len();
for i in 0..len {
if let Some(v) = r.get(i) {
out.push((VmValue::Int(i), VmValue::Int(v)));
}
}
Ok(out)
}
VmValue::Nil => Ok(Vec::new()),
other => Err(format!(
"cannot iterate over {} — expected list, dict, set, or range",
other.type_name()
)),
}
}
fn display_value(v: &VmValue) -> String {
match v {
VmValue::Nil => String::new(), other => other.display(),
}
}
fn apply_filter(
name: &str,
v: &VmValue,
args: &[VmValue],
line: usize,
col: usize,
) -> Result<VmValue, TemplateError> {
let bad_arity = || {
TemplateError::new(
line,
col,
format!("filter `{name}` got wrong number of arguments"),
)
};
let need = |n: usize, args: &[VmValue]| -> Result<(), TemplateError> {
if args.len() == n {
Ok(())
} else {
Err(bad_arity())
}
};
let str_of = |v: &VmValue| -> String { display_value(v) };
match name {
"upper" => {
need(0, args)?;
Ok(VmValue::String(Rc::from(str_of(v).to_uppercase())))
}
"lower" => {
need(0, args)?;
Ok(VmValue::String(Rc::from(str_of(v).to_lowercase())))
}
"trim" => {
need(0, args)?;
Ok(VmValue::String(Rc::from(str_of(v).trim())))
}
"capitalize" => {
need(0, args)?;
let s = str_of(v);
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
if let Some(c) = chars.next() {
out.extend(c.to_uppercase());
}
for c in chars {
out.extend(c.to_lowercase());
}
Ok(VmValue::String(Rc::from(out)))
}
"title" => {
need(0, args)?;
let s = str_of(v);
let mut out = String::with_capacity(s.len());
let mut at_start = true;
for c in s.chars() {
if c.is_whitespace() {
at_start = true;
out.push(c);
} else if at_start {
out.extend(c.to_uppercase());
at_start = false;
} else {
out.extend(c.to_lowercase());
}
}
Ok(VmValue::String(Rc::from(out)))
}
"length" => {
need(0, args)?;
let n: i64 = match v {
VmValue::String(s) => s.chars().count() as i64,
VmValue::List(items) => items.len() as i64,
VmValue::Set(items) => items.len() as i64,
VmValue::Dict(d) => d.len() as i64,
VmValue::Range(r) => r.len(),
VmValue::Nil => 0,
other => {
return Err(TemplateError::new(
line,
col,
format!("`length` not defined for {}", other.type_name()),
))
}
};
Ok(VmValue::Int(n))
}
"first" => {
need(0, args)?;
Ok(match v {
VmValue::List(items) => items.first().cloned().unwrap_or(VmValue::Nil),
VmValue::Set(items) => items.first().cloned().unwrap_or(VmValue::Nil),
VmValue::String(s) => s
.chars()
.next()
.map(|c| VmValue::String(Rc::from(c.to_string())))
.unwrap_or(VmValue::Nil),
_ => VmValue::Nil,
})
}
"last" => {
need(0, args)?;
Ok(match v {
VmValue::List(items) => items.last().cloned().unwrap_or(VmValue::Nil),
VmValue::Set(items) => items.last().cloned().unwrap_or(VmValue::Nil),
VmValue::String(s) => s
.chars()
.last()
.map(|c| VmValue::String(Rc::from(c.to_string())))
.unwrap_or(VmValue::Nil),
_ => VmValue::Nil,
})
}
"reverse" => {
need(0, args)?;
Ok(match v {
VmValue::List(items) => {
let mut out: Vec<VmValue> = items.as_ref().clone();
out.reverse();
VmValue::List(Rc::new(out))
}
VmValue::String(s) => {
VmValue::String(Rc::from(s.chars().rev().collect::<String>()))
}
_ => v.clone(),
})
}
"join" => {
need(1, args)?;
let sep = str_of(&args[0]);
let parts: Vec<String> = match v {
VmValue::List(items) => items.iter().map(str_of).collect(),
VmValue::Set(items) => items.iter().map(str_of).collect(),
VmValue::String(s) => return Ok(VmValue::String(s.clone())),
_ => {
return Err(TemplateError::new(
line,
col,
format!("`join` requires a list (got {})", v.type_name()),
))
}
};
Ok(VmValue::String(Rc::from(parts.join(&sep))))
}
"default" => {
need(1, args)?;
if truthy(v) {
Ok(v.clone())
} else {
Ok(args[0].clone())
}
}
"json" => {
if args.len() > 1 {
return Err(bad_arity());
}
let pretty = args.first().map(truthy).unwrap_or(false);
let jv = crate::llm::helpers::vm_value_to_json(v);
let s = if pretty {
serde_json::to_string_pretty(&jv)
} else {
serde_json::to_string(&jv)
}
.map_err(|e| TemplateError::new(line, col, format!("json serialization: {e}")))?;
Ok(VmValue::String(Rc::from(s)))
}
"indent" => {
if args.is_empty() || args.len() > 2 {
return Err(bad_arity());
}
let n = match &args[0] {
VmValue::Int(n) => (*n).max(0) as usize,
_ => {
return Err(TemplateError::new(
line,
col,
"`indent` requires an integer width",
))
}
};
let indent_first = args.get(1).map(truthy).unwrap_or(false);
let pad: String = " ".repeat(n);
let s = str_of(v);
let mut out = String::with_capacity(s.len() + n * 4);
for (i, line) in s.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
if !line.is_empty() && (i > 0 || indent_first) {
out.push_str(&pad);
}
out.push_str(line);
}
Ok(VmValue::String(Rc::from(out)))
}
"lines" => {
need(0, args)?;
let s = str_of(v);
let list: Vec<VmValue> = s
.split('\n')
.map(|p| VmValue::String(Rc::from(p)))
.collect();
Ok(VmValue::List(Rc::new(list)))
}
"escape_md" => {
need(0, args)?;
let s = str_of(v);
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+'
| '-' | '.' | '!' | '|' | '<' | '>' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
Ok(VmValue::String(Rc::from(out)))
}
"replace" => {
need(2, args)?;
let s = str_of(v);
let from = str_of(&args[0]);
let to = str_of(&args[1]);
Ok(VmValue::String(Rc::from(s.replace(&from, &to))))
}
other => Err(TemplateError::new(
line,
col,
format!("unknown filter `{other}`"),
)),
}
}
fn split_top_level(s: &str, delim: char) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut in_str = false;
let mut quote = '"';
let bytes = s.as_bytes();
let mut start = 0;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i] as char;
if in_str {
if b == '\\' {
i += 2;
continue;
}
if b == quote {
in_str = false;
}
i += 1;
continue;
}
match b {
'"' | '\'' => {
in_str = true;
quote = b;
}
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
c if c == delim && depth == 0 => {
out.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
i += 1;
}
out.push(&s[start..]);
out
}
fn split_once_top_level(s: &str, delim: char) -> Option<(&str, &str)> {
let mut depth = 0i32;
let mut in_str = false;
let mut quote = '"';
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i] as char;
if in_str {
if b == '\\' {
i += 2;
continue;
}
if b == quote {
in_str = false;
}
i += 1;
continue;
}
match b {
'"' | '\'' => {
in_str = true;
quote = b;
}
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
c if c == delim && depth == 0 => {
return Some((&s[..i], &s[i + 1..]));
}
_ => {}
}
i += 1;
}
None
}
fn split_once_keyword<'a>(s: &'a str, kw: &str) -> Option<(&'a str, &'a str)> {
let mut depth = 0i32;
let mut in_str = false;
let mut quote = '"';
let bytes = s.as_bytes();
let kw_bytes = kw.as_bytes();
let mut i = 0;
while i + kw_bytes.len() <= bytes.len() {
let b = bytes[i] as char;
if in_str {
if b == '\\' {
i += 2;
continue;
}
if b == quote {
in_str = false;
}
i += 1;
continue;
}
match b {
'"' | '\'' => {
in_str = true;
quote = b;
i += 1;
continue;
}
'(' | '[' | '{' => {
depth += 1;
i += 1;
continue;
}
')' | ']' | '}' => {
depth -= 1;
i += 1;
continue;
}
_ => {}
}
if depth == 0 && &bytes[i..i + kw_bytes.len()] == kw_bytes {
return Some((&s[..i], &s[i + kw_bytes.len()..]));
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn dict(pairs: &[(&str, VmValue)]) -> BTreeMap<String, VmValue> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
fn s(v: &str) -> VmValue {
VmValue::String(Rc::from(v))
}
fn render(tpl: &str, b: &BTreeMap<String, VmValue>) -> String {
render_template_result(tpl, Some(b), None, None).unwrap()
}
#[test]
fn bare_interp() {
let b = dict(&[("name", s("Alice"))]);
assert_eq!(render("hi {{name}}!", &b), "hi Alice!");
}
#[test]
fn bare_interp_missing_passthrough() {
let b = dict(&[]);
assert_eq!(render("hi {{name}}!", &b), "hi {{name}}!");
}
#[test]
fn legacy_if_truthy() {
let b = dict(&[("x", VmValue::Bool(true))]);
assert_eq!(render("{{if x}}yes{{end}}", &b), "yes");
}
#[test]
fn legacy_if_falsey() {
let b = dict(&[("x", VmValue::Bool(false))]);
assert_eq!(render("{{if x}}yes{{end}}", &b), "");
}
#[test]
fn if_else() {
let b = dict(&[("x", VmValue::Bool(false))]);
assert_eq!(render("{{if x}}A{{else}}B{{end}}", &b), "B");
}
#[test]
fn if_elif_else() {
let b = dict(&[("n", VmValue::Int(2))]);
let tpl = "{{if n == 1}}one{{elif n == 2}}two{{elif n == 3}}three{{else}}many{{end}}";
assert_eq!(render(tpl, &b), "two");
}
#[test]
fn for_loop_basic() {
let items = VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")]));
let b = dict(&[("xs", items)]);
assert_eq!(render("{{for x in xs}}{{x}},{{end}}", &b), "a,b,c,");
}
#[test]
fn for_loop_vars() {
let items = VmValue::List(Rc::new(vec![s("a"), s("b")]));
let b = dict(&[("xs", items)]);
let tpl = "{{for x in xs}}{{loop.index}}:{{x}}{{if !loop.last}},{{end}}{{end}}";
assert_eq!(render(tpl, &b), "1:a,2:b");
}
#[test]
fn for_empty_else() {
let b = dict(&[("xs", VmValue::List(Rc::new(vec![])))]);
assert_eq!(render("{{for x in xs}}A{{else}}empty{{end}}", &b), "empty");
}
#[test]
fn for_dict_kv() {
let mut d: BTreeMap<String, VmValue> = BTreeMap::new();
d.insert("a".into(), VmValue::Int(1));
d.insert("b".into(), VmValue::Int(2));
let b = dict(&[("m", VmValue::Dict(Rc::new(d)))]);
assert_eq!(
render("{{for k, v in m}}{{k}}={{v}};{{end}}", &b),
"a=1;b=2;"
);
}
#[test]
fn nested_path() {
let mut inner: BTreeMap<String, VmValue> = BTreeMap::new();
inner.insert("name".into(), s("Alice"));
let b = dict(&[("user", VmValue::Dict(Rc::new(inner)))]);
assert_eq!(render("{{user.name}}", &b), "Alice");
}
#[test]
fn list_index() {
let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")])))]);
assert_eq!(render("{{xs[1]}}", &b), "b");
}
#[test]
fn filter_upper() {
let b = dict(&[("n", s("alice"))]);
assert_eq!(render("{{n | upper}}", &b), "ALICE");
}
#[test]
fn filter_default() {
let b = dict(&[("n", s(""))]);
assert_eq!(render("{{n | default: \"anon\"}}", &b), "anon");
}
#[test]
fn filter_join() {
let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b")])))]);
assert_eq!(render("{{xs | join: \", \"}}", &b), "a, b");
}
#[test]
fn comparison_ops() {
let b = dict(&[("n", VmValue::Int(5))]);
assert_eq!(render("{{if n > 3}}big{{end}}", &b), "big");
assert_eq!(render("{{if n >= 5 and n < 10}}ok{{end}}", &b), "ok");
}
#[test]
fn bool_not() {
let b = dict(&[("x", VmValue::Bool(false))]);
assert_eq!(render("{{if not x}}yes{{end}}", &b), "yes");
assert_eq!(render("{{if !x}}yes{{end}}", &b), "yes");
}
#[test]
fn raw_block() {
let b = dict(&[]);
assert_eq!(
render("A {{ raw }}{{not-a-directive}}{{ endraw }} B", &b),
"A {{not-a-directive}} B"
);
}
#[test]
fn comment_stripped() {
let b = dict(&[("x", s("hi"))]);
assert_eq!(render("A{{# hidden #}}B{{x}}", &b), "ABhi");
}
#[test]
fn whitespace_trim() {
let b = dict(&[("x", s("v"))]);
let tpl = "line1\n {{- x -}} \nline2";
assert_eq!(render(tpl, &b), "line1vline2");
}
#[test]
fn filter_json() {
let b = dict(&[(
"x",
VmValue::Dict(Rc::new({
let mut m = BTreeMap::new();
m.insert("a".into(), VmValue::Int(1));
m
})),
)]);
assert_eq!(render("{{x | json}}", &b), r#"{"a":1}"#);
}
#[test]
fn error_unterminated_if() {
let b = dict(&[("x", VmValue::Bool(true))]);
let r = render_template_result("{{if x}}open", Some(&b), None, None);
assert!(r.is_err());
}
#[test]
fn error_unknown_filter() {
let b = dict(&[("x", s("a"))]);
let r = render_template_result("{{x | bogus}}", Some(&b), None, None);
assert!(r.is_err());
}
#[test]
fn include_with() {
use std::fs;
let dir = tempdir();
let partial = dir.join("p.prompt");
fs::write(&partial, "[{{name}}]").unwrap();
let parent = dir.join("main.prompt");
fs::write(
&parent,
r#"hello {{ include "p.prompt" with { name: who } }}!"#,
)
.unwrap();
let b = dict(&[("who", s("world"))]);
let src = fs::read_to_string(&parent).unwrap();
let out = render_template_result(&src, Some(&b), Some(&dir), Some(&parent)).unwrap();
assert_eq!(out, "hello [world]!");
}
#[test]
fn include_cycle_detected() {
use std::fs;
let dir = tempdir();
let a = dir.join("a.prompt");
let b = dir.join("b.prompt");
fs::write(&a, r#"A{{ include "b.prompt" }}"#).unwrap();
fs::write(&b, r#"B{{ include "a.prompt" }}"#).unwrap();
let src = fs::read_to_string(&a).unwrap();
let r = render_template_result(&src, None, Some(&dir), Some(&a));
assert!(r.is_err());
assert!(r.unwrap_err().kind.contains("circular include"));
}
fn tempdir() -> PathBuf {
let base = std::env::temp_dir().join(format!("harn-tpl-{}", nanoid()));
std::fs::create_dir_all(&base).unwrap();
base
}
fn nanoid() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
format!(
"{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
)
}
}