use std::fmt::Display;
pub enum Value<'a> {
Str(&'a str),
Int(i64),
}
impl Value<'_> {
fn write_to(&self, out: &mut String) {
match self {
Value::Str(s) => out.push_str(s),
Value::Int(n) => {
use std::fmt::Write;
let _ = write!(out, "{n}");
}
}
}
}
impl<'a> From<&'a str> for Value<'a> {
fn from(s: &'a str) -> Self {
Value::Str(s)
}
}
pub trait Context {
fn value(&self, name: &str) -> Option<Value<'_>>;
fn flag(&self, name: &str) -> Option<bool>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Node {
Text(String),
Var(String),
Section { name: String, body: Vec<Node> },
Inverted { name: String, body: Vec<Node> },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
UnclosedTag,
MismatchedClose {
found: String,
},
UnclosedSection {
name: String,
},
}
impl Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::UnclosedTag => write!(f, "unclosed tag: a {{{{ has no matching }}}}"),
ParseError::MismatchedClose { found } => {
write!(f, "mismatched closing tag {{{{/{found}}}}}")
}
ParseError::UnclosedSection { name } => {
write!(f, "unclosed section {{{{#{name}}}}} (or inverted)")
}
}
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone)]
pub struct Template {
nodes: Vec<Node>,
}
impl Template {
pub fn parse(source: &str) -> Result<Self, ParseError> {
let tokens = lex(source)?;
let mut iter = tokens.into_iter().peekable();
let nodes = parse_nodes(&mut iter, None)?;
Ok(Template { nodes })
}
pub fn render(&self, context: &dyn Context) -> String {
let mut out = String::with_capacity(self.estimated_capacity());
render_nodes(&self.nodes, context, &mut out);
let trimmed = out.trim_end();
out.truncate(trimmed.len());
out
}
fn estimated_capacity(&self) -> usize {
fn size(nodes: &[Node]) -> usize {
nodes
.iter()
.map(|n| match n {
Node::Text(t) => t.len(),
Node::Var(_) => 16,
Node::Section { body, .. } | Node::Inverted { body, .. } => size(body),
})
.sum()
}
size(&self.nodes) + 256
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Text(String),
Var(String),
SectionOpen(String),
InvertedOpen(String),
Close(String),
}
fn lex(source: &str) -> Result<Vec<Token>, ParseError> {
let bytes = source.as_bytes();
let mut tokens = Vec::new();
let mut text_start = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
if i > text_start {
tokens.push(Token::Text(source[text_start..i].to_owned()));
}
let triple = i + 2 < bytes.len() && bytes[i + 2] == b'{';
let open_len = if triple { 3 } else { 2 };
let close_pat: &[u8] = if triple { b"}}}" } else { b"}}" };
let body_start = i + open_len;
let close_rel =
find_subslice(&bytes[body_start..], close_pat).ok_or(ParseError::UnclosedTag)?;
let body = &source[body_start..body_start + close_rel];
let tag = classify_tag(body, triple);
tokens.push(tag);
i = body_start + close_rel + close_pat.len();
text_start = i;
} else {
i += 1;
}
}
if text_start < bytes.len() {
tokens.push(Token::Text(source[text_start..].to_owned()));
}
Ok(tokens)
}
fn classify_tag(body: &str, triple: bool) -> Token {
if triple {
return Token::Var(body.trim().to_owned());
}
let trimmed = body.trim_start();
let mut chars = trimmed.chars();
match chars.next() {
Some('#') => Token::SectionOpen(chars.as_str().trim().to_owned()),
Some('^') => Token::InvertedOpen(chars.as_str().trim().to_owned()),
Some('/') => Token::Close(chars.as_str().trim().to_owned()),
Some('&') => Token::Var(chars.as_str().trim().to_owned()),
_ => Token::Var(trimmed.trim().to_owned()),
}
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn parse_nodes(
tokens: &mut std::iter::Peekable<std::vec::IntoIter<Token>>,
open: Option<&str>,
) -> Result<Vec<Node>, ParseError> {
let mut nodes = Vec::new();
while let Some(token) = tokens.next() {
match token {
Token::Text(text) => nodes.push(Node::Text(text)),
Token::Var(name) => nodes.push(Node::Var(name)),
Token::SectionOpen(name) => {
let body = parse_nodes(tokens, Some(&name))?;
nodes.push(Node::Section { name, body });
}
Token::InvertedOpen(name) => {
let body = parse_nodes(tokens, Some(&name))?;
nodes.push(Node::Inverted { name, body });
}
Token::Close(name) => {
return match open {
Some(expected) if expected == name => Ok(nodes),
_ => Err(ParseError::MismatchedClose { found: name }),
};
}
}
}
match open {
Some(name) => Err(ParseError::UnclosedSection {
name: name.to_owned(),
}),
None => Ok(nodes),
}
}
fn render_nodes(nodes: &[Node], context: &dyn Context, out: &mut String) {
for node in nodes {
match node {
Node::Text(text) => out.push_str(text),
Node::Var(name) => {
if let Some(value) = context.value(name) {
value.write_to(out);
}
}
Node::Section { name, body } => {
if context.flag(name) == Some(true) {
render_nodes(body, context, out);
}
}
Node::Inverted { name, body } => {
if context.flag(name) == Some(false) {
render_nodes(body, context, out);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestCtx;
impl Context for TestCtx {
fn value(&self, name: &str) -> Option<Value<'_>> {
match name {
"name" => Some(Value::Str("world")),
"num" => Some(Value::Int(42)),
_ => None,
}
}
fn flag(&self, name: &str) -> Option<bool> {
match name {
"on" => Some(true),
"off" => Some(false),
_ => None,
}
}
}
fn render(src: &str) -> String {
Template::parse(src).unwrap().render(&TestCtx)
}
#[test]
fn plain_variable() {
assert_eq!(render("hello {{name}}!"), "hello world!");
}
#[test]
fn unescaped_variants_match_plain() {
assert_eq!(render("{{name}}|{{&name}}|{{{name}}}"), "world|world|world");
}
#[test]
fn integer_value_is_bare() {
assert_eq!(render("n={{num}};"), "n=42;");
}
#[test]
fn unknown_variable_is_empty() {
assert_eq!(render("[{{missing}}]"), "[]");
}
#[test]
fn section_renders_when_true() {
assert_eq!(render("{{#on}}yes{{/on}}"), "yes");
assert_eq!(render("{{#off}}yes{{/off}}"), "");
}
#[test]
fn inverted_renders_when_false() {
assert_eq!(render("{{^off}}no{{/off}}"), "no");
assert_eq!(render("{{^on}}no{{/on}}"), "");
}
#[test]
fn nested_sections() {
let src = "{{#on}}A{{^off}}B{{/off}}C{{/on}}";
assert_eq!(render(src), "ABC");
}
#[test]
fn literal_braces_without_pair_are_text() {
assert_eq!(render("a { b } c"), "a { b } c");
}
#[test]
fn trailing_whitespace_is_trimmed() {
assert_eq!(render("x{{name}} \r\n\r\n"), "xworld");
}
#[test]
fn interior_whitespace_is_preserved() {
let src = "a\n {{#on}}\n body\n {{/on}}\n z";
assert_eq!(render(src), "a\n \n body\n \n z");
}
#[test]
fn unclosed_tag_errors() {
assert_eq!(
Template::parse("a {{name").unwrap_err(),
ParseError::UnclosedTag
);
}
#[test]
fn unclosed_section_errors() {
assert!(matches!(
Template::parse("{{#on}}body").unwrap_err(),
ParseError::UnclosedSection { .. }
));
}
#[test]
fn mismatched_close_errors() {
assert!(matches!(
Template::parse("{{#on}}body{{/off}}").unwrap_err(),
ParseError::MismatchedClose { .. }
));
}
}