Menu
Public repository / 9df51040bf1687e4dc8a91557f02df52e8d5cf3a
AkurAI-Framework / crates/template/src/parse.rs
Latest change efd5eb833824dca5c86104004c11bc6d39ff1a72 - feat(template): pure-std SSR template engine + component showcase by Ólafur Búi Ólafsson
//! Tokenize a template string and build its [`Node`] tree.
//!
//! Markers: `{{ }}` / `{{{ }}}` (interpolation), `{% %}` (tags), `{# #}`
//! (comments). Everything else is literal text.
use crate::ast::{Node, Path};
use crate::error::Error;
/// Parse a template source into its AST.
pub fn parse(source: &str) -> Result<Vec<Node>, Error> {
let tokens = tokenize(source)?;
let mut p = TokenStream { tokens, pos: 0 };
let (nodes, term) = p.block()?;
match term {
Term::Eof => Ok(nodes),
Term::Else => Err(Error::new("'else' without matching 'if'")),
Term::EndIf => Err(Error::new("'endif' without matching 'if'")),
Term::EndFor => Err(Error::new("'endfor' without matching 'for'")),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Text(String),
Var { path: Path, escape: bool },
If(Path),
Else,
EndIf,
For { var: String, path: Path },
EndFor,
Include(String),
}
fn tokenize(source: &str) -> Result<Vec<Token>, Error> {
let mut tokens = Vec::new();
let mut rest = source;
while !rest.is_empty() {
// Find the earliest opening marker.
let next = ["{{", "{%", "{#"]
.iter()
.filter_map(|m| rest.find(m).map(|i| (i, *m)))
.min_by_key(|(i, _)| *i);
let (idx, marker) = match next {
Some(found) => found,
None => {
tokens.push(Token::Text(rest.to_string()));
break;
}
};
if idx > 0 {
tokens.push(Token::Text(rest[..idx].to_string()));
}
rest = &rest[idx..];
match marker {
"{{" => {
let triple = rest.starts_with("{{{");
let (open, close): (usize, &str) = if triple { (3, "}}}") } else { (2, "}}") };
let end = rest
.find(close)
.ok_or_else(|| Error::new("unclosed '{{'"))?;
let inner = rest[open..end].trim();
tokens.push(Token::Var {
path: parse_path(inner),
escape: !triple,
});
rest = &rest[end + close.len()..];
}
"{%" => {
let end = rest.find("%}").ok_or_else(|| Error::new("unclosed '{%'"))?;
tokens.push(parse_tag(rest[2..end].trim())?);
rest = &rest[end + 2..];
}
"{#" => {
let end = rest.find("#}").ok_or_else(|| Error::new("unclosed '{#'"))?;
rest = &rest[end + 2..];
}
_ => unreachable!(),
}
}
Ok(tokens)
}
fn parse_tag(inner: &str) -> Result<Token, Error> {
if inner == "else" {
return Ok(Token::Else);
}
if inner == "endif" {
return Ok(Token::EndIf);
}
if inner == "endfor" {
return Ok(Token::EndFor);
}
if let Some(cond) = inner.strip_prefix("if ") {
return Ok(Token::If(parse_path(cond.trim())));
}
if let Some(rest) = inner.strip_prefix("for ") {
// `for <var> in <path>`
let mut parts = rest.split_whitespace();
let var = parts
.next()
.ok_or_else(|| Error::new("'for' needs a variable"))?;
match parts.next() {
Some("in") => {}
_ => return Err(Error::new("'for' must be 'for <var> in <path>'")),
}
let path = parts
.next()
.ok_or_else(|| Error::new("'for' needs an iterable"))?;
return Ok(Token::For {
var: var.to_string(),
path: parse_path(path),
});
}
if let Some(rest) = inner.strip_prefix("include ") {
let name = rest.trim().trim_matches('"');
return Ok(Token::Include(name.to_string()));
}
Err(Error::new(format!("unknown tag '{{% {inner} %}}'")))
}
fn parse_path(s: &str) -> Path {
s.split('.')
.map(str::trim)
.filter(|seg| !seg.is_empty())
.map(String::from)
.collect()
}
#[derive(Debug, PartialEq, Eq)]
enum Term {
Eof,
Else,
EndIf,
EndFor,
}
struct TokenStream {
tokens: Vec<Token>,
pos: usize,
}
impl TokenStream {
/// Parse a sequence of nodes until a block terminator or EOF.
fn block(&mut self) -> Result<(Vec<Node>, Term), Error> {
let mut nodes = Vec::new();
loop {
let token = match self.tokens.get(self.pos) {
None => return Ok((nodes, Term::Eof)),
Some(t) => t.clone(),
};
self.pos += 1;
match token {
Token::Text(t) => nodes.push(Node::Text(t)),
Token::Var { path, escape } => nodes.push(Node::Var { path, escape }),
Token::Include(name) => nodes.push(Node::Include(name)),
Token::Else => return Ok((nodes, Term::Else)),
Token::EndIf => return Ok((nodes, Term::EndIf)),
Token::EndFor => return Ok((nodes, Term::EndFor)),
Token::If(cond) => {
let (body, term) = self.block()?;
let else_body = match term {
Term::EndIf => Vec::new(),
Term::Else => {
let (eb, t2) = self.block()?;
if t2 != Term::EndIf {
return Err(Error::new("unclosed 'if' (expected 'endif')"));
}
eb
}
_ => return Err(Error::new("unclosed 'if' (expected 'endif')")),
};
nodes.push(Node::If {
cond,
body,
else_body,
});
}
Token::For { var, path } => {
let (body, term) = self.block()?;
if term != Term::EndFor {
return Err(Error::new("unclosed 'for' (expected 'endfor')"));
}
nodes.push(Node::For { var, path, body });
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_text_and_vars() {
let ast = parse("Hi {{ name }}!").unwrap();
assert_eq!(
ast,
vec![
Node::Text("Hi ".into()),
Node::Var {
path: vec!["name".into()],
escape: true
},
Node::Text("!".into()),
]
);
}
#[test]
fn triple_brace_is_raw() {
let ast = parse("{{{ html }}}").unwrap();
assert_eq!(
ast,
vec![Node::Var {
path: vec!["html".into()],
escape: false
}]
);
}
#[test]
fn parses_if_else() {
let ast = parse("{% if ok %}Y{% else %}N{% endif %}").unwrap();
assert_eq!(
ast,
vec![Node::If {
cond: vec!["ok".into()],
body: vec![Node::Text("Y".into())],
else_body: vec![Node::Text("N".into())],
}]
);
}
#[test]
fn parses_for() {
let ast = parse("{% for x in items %}{{ x }}{% endfor %}").unwrap();
assert_eq!(
ast,
vec![Node::For {
var: "x".into(),
path: vec!["items".into()],
body: vec![Node::Var {
path: vec!["x".into()],
escape: true
}],
}]
);
}
#[test]
fn comments_are_dropped() {
assert_eq!(
parse("a{# hidden #}b").unwrap(),
vec![Node::Text("a".into()), Node::Text("b".into())]
);
}
#[test]
fn dotted_path() {
let ast = parse("{{ user.name }}").unwrap();
assert_eq!(
ast,
vec![Node::Var {
path: vec!["user".into(), "name".into()],
escape: true
}]
);
}
#[test]
fn rejects_unbalanced() {
assert!(parse("{% if a %}x").is_err());
assert!(parse("{% endfor %}").is_err());
assert!(parse("{{ a").is_err());
assert!(parse("{% bogus %}").is_err());
}
}