AkurAI Build
Menu

Public repository / 3a1d4dc0df9425a910df82402300a2aeb40318ae

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());
    }
}