skabelon 0.2.3

Simple templating engine for rust using angular syntax
Documentation
use crate::{
    nodes::{CompareOp, Condition, ForLoop, If, Include, LocalValue, Node, Operand},
    templates::Templates,
};
use serde_json::Value;
use std::collections::HashMap;

pub struct ContextStack<'a> {
    scopes: Vec<HashMap<String, Value>>,
    global: &'a Value,
}

impl<'a> ContextStack<'a> {
    pub fn new(global: &'a Value) -> Self {
        Self {
            scopes: Vec::new(),
            global,
        }
    }

    pub fn push_scope_with_capacity(&mut self, capacity: usize) {
        self.scopes.push(HashMap::with_capacity(capacity));
    }

    pub fn pop_scope(&mut self) {
        self.scopes.pop();
    }

    pub fn set(&mut self, key: String, value: Value) {
        if let Some(scope) = self.scopes.last_mut() {
            scope.insert(key, value);
        }
    }

    pub fn set_str(&mut self, key: &str, value: Value) {
        if let Some(scope) = self.scopes.last_mut() {
            scope.insert(key.to_owned(), value);
        }
    }

    pub fn get(&self, key: &str) -> Option<&Value> {
        for scope in self.scopes.iter().rev() {
            if let Some(val) = scope.get(key) {
                return Some(val);
            }
        }

        self.global.get(key)
    }
}

fn trim_single_trailing_space(out: &mut String) -> bool {
    if out.ends_with(' ') {
        out.pop();
        true
    } else {
        false
    }
}

fn estimate_capacity(nodes: &[Node]) -> usize {
    nodes
        .iter()
        .map(|node| match node {
            Node::Text(s) => s.len(),
            Node::If(If {
                conditions,
                otherwise,
            }) => {
                let condition_max = conditions
                    .iter()
                    .map(|(_, body)| estimate_capacity(body))
                    .max()
                    .unwrap_or(0);

                let otherwise_len = otherwise
                    .as_ref()
                    .map(|body| estimate_capacity(body))
                    .unwrap_or(0);

                condition_max.max(otherwise_len)
            }
            Node::Forloop(ForLoop { body, .. }) => estimate_capacity(body),
            Node::Include(Include { body, .. }) => estimate_capacity(body),
            _ => 0,
        })
        .sum()
}

pub fn render_nodes(
    nodes: &[Node],
    ctx_stack: &mut ContextStack,
    templates: &Templates,
    content_html: Option<&str>,
) -> String {
    let mut out = String::with_capacity(estimate_capacity(nodes));
    render_nodes_into(nodes, ctx_stack, templates, content_html, &mut out);
    out
}

fn render_nodes_into(
    nodes: &[Node],
    ctx_stack: &mut ContextStack,
    templates: &Templates,
    content_html: Option<&str>,
    out: &mut String,
) {
    let mut suppress_one_leading_space = false;

    for node in nodes {
        match node {
            Node::Text(s) => {
                if suppress_one_leading_space {
                    if let Some(rest) = s.strip_prefix(' ') {
                        out.push_str(rest);
                    } else {
                        out.push_str(s);
                    }
                    suppress_one_leading_space = false;
                } else {
                    out.push_str(s);
                }
            }

            Node::VariableBlock(path) => {
                if is_content_path(path) {
                    write_content(content_html, out, &mut suppress_one_leading_space);
                } else if let Some(val) = resolve_path(path, ctx_stack) {
                    value_to_string_into(val, out);
                    suppress_one_leading_space = false;
                }
            }

            Node::If(If {
                conditions,
                otherwise,
            }) => {
                let before = out.len();
                let mut taken = false;

                for (cond, body) in conditions {
                    if evaluate_condition(cond, ctx_stack) {
                        render_nodes_into(body, ctx_stack, templates, content_html, out);
                        taken = true;
                        break;
                    }
                }

                if !taken && let Some(body) = otherwise {
                    render_nodes_into(body, ctx_stack, templates, content_html, out);
                }

                if out.len() == before {
                    let popped = trim_single_trailing_space(out);
                    suppress_one_leading_space = !popped;
                } else {
                    suppress_one_leading_space = false;
                }
            }

            Node::Forloop(ForLoop {
                value,
                container,
                body,
            }) => {
                let before = out.len();

                let items = resolve_path(container, ctx_stack)
                    .and_then(Value::as_array)
                    .cloned();

                if let Some(items) = items {
                    ctx_stack.push_scope_with_capacity(2);

                    for (i, item) in items.into_iter().enumerate() {
                        ctx_stack.set(value.clone(), item);
                        ctx_stack.set_str("index", Value::from(i));
                        render_nodes_into(body, ctx_stack, templates, content_html, out);
                    }

                    ctx_stack.pop_scope();
                }

                if out.len() == before {
                    let popped = trim_single_trailing_space(out);
                    suppress_one_leading_space = !popped;
                } else {
                    suppress_one_leading_space = false;
                }
            }

            Node::Include(Include {
                path,
                body,
                local_ctx,
            }) => {
                if let Some(partial_nodes) = templates.get(path) {
                    let parent_rendered_content = render_nodes(body, ctx_stack, templates, None);

                    let null_global = Value::Null;
                    let mut partial_stack = ContextStack::new(&null_global);
                    partial_stack.push_scope_with_capacity(local_ctx.len());

                    for (k, local_val) in local_ctx {
                        let value = match local_val {
                            LocalValue::Literal(val) => val.clone(),
                            LocalValue::Path(path) => resolve_path(path, ctx_stack)
                                .cloned()
                                .unwrap_or(Value::Null),
                        };

                        partial_stack.set(k.clone(), value);
                    }

                    let before = out.len();

                    render_nodes_into(
                        partial_nodes,
                        &mut partial_stack,
                        templates,
                        Some(&parent_rendered_content),
                        out,
                    );

                    partial_stack.pop_scope();

                    if out.len() == before {
                        let popped = trim_single_trailing_space(out);
                        suppress_one_leading_space = !popped;
                    } else {
                        suppress_one_leading_space = false;
                    }
                } else {
                    out.push_str("<!-- Missing defer: ");
                    out.push_str(path);
                    out.push_str(" -->");
                    suppress_one_leading_space = false;
                }
            }

            Node::ContentPlaceholder => {
                write_content(content_html, out, &mut suppress_one_leading_space);
            }
            Node::Error(msg) => {
                out.push_str("<template-error>");
                out.push_str(msg);
                out.push_str("</template-error>");
            }
        }
    }
}

fn is_content_path(path: &[String]) -> bool {
    path.len() == 1 && path[0] == "__CONTENT__"
}

fn write_content(
    content_html: Option<&str>,
    out: &mut String,
    suppress_one_leading_space: &mut bool,
) {
    match content_html {
        Some(html) if !html.is_empty() => {
            out.push_str(html);
            *suppress_one_leading_space = false;
        }
        _ => {
            let popped = trim_single_trailing_space(out);
            *suppress_one_leading_space = !popped;
        }
    }
}

fn evaluate_condition(cond: &Condition, ctx_stack: &ContextStack) -> bool {
    match cond {
        Condition::Literal(b) => *b,
        Condition::Path(path) => evaluate_path_truthiness(path, ctx_stack),
        Condition::And(conds) => conds.iter().all(|c| evaluate_condition(c, ctx_stack)),
        Condition::Or(conds) => conds.iter().any(|c| evaluate_condition(c, ctx_stack)),
        Condition::Not(inner) => !evaluate_condition(inner, ctx_stack),
        Condition::Compare { left, op, right } => {
            let l = resolve_operand(left, ctx_stack);
            let r = resolve_operand(right, ctx_stack);

            match (l, r) {
                (Some(lv), Some(rv)) => compare_values(lv, op, rv),
                _ => false,
            }
        }
    }
}

fn resolve_operand<'a>(opnd: &'a Operand, ctx_stack: &'a ContextStack) -> Option<&'a Value> {
    match opnd {
        Operand::Literal(v) => Some(v),
        Operand::Path(p) => resolve_path(p, ctx_stack),
    }
}

fn compare_values(left: &Value, op: &CompareOp, right: &Value) -> bool {
    match (left, right) {
        (Value::String(ls), Value::String(rs)) => match op {
            CompareOp::Eq => ls == rs,
            CompareOp::Ne => ls != rs,
            CompareOp::Lt => ls < rs,
            CompareOp::Gt => ls > rs,
            CompareOp::Le => ls <= rs,
            CompareOp::Ge => ls >= rs,
        },
        (Value::Number(ln), Value::Number(rn)) => match (ln.as_f64(), rn.as_f64()) {
            (Some(lf), Some(rf)) => match op {
                CompareOp::Eq => lf == rf,
                CompareOp::Ne => lf != rf,
                CompareOp::Lt => lf < rf,
                CompareOp::Gt => lf > rf,
                CompareOp::Le => lf <= rf,
                CompareOp::Ge => lf >= rf,
            },
            _ => match op {
                CompareOp::Eq => ln == rn,
                CompareOp::Ne => ln != rn,
                _ => false,
            },
        },
        (Value::Bool(lb), Value::Bool(rb)) => match op {
            CompareOp::Eq => lb == rb,
            CompareOp::Ne => lb != rb,
            _ => false,
        },
        _ => match op {
            CompareOp::Eq => left == right,
            CompareOp::Ne => left != right,
            _ => false,
        },
    }
}

fn evaluate_path_truthiness(path: &[String], ctx_stack: &ContextStack) -> bool {
    if path.len() == 1 {
        match path[0].as_str() {
            "true" => return true,
            "false" => return false,
            raw => {
                if let Ok(num) = raw.parse::<f64>() {
                    return num != 0.0;
                }
            }
        }
    }

    match resolve_path(path, ctx_stack) {
        Some(Value::Bool(b)) => *b,
        Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0),
        Some(Value::String(s)) => !s.is_empty(),
        Some(Value::Null) => false,
        Some(Value::Array(a)) => !a.is_empty(),
        Some(Value::Object(o)) => !o.is_empty(),
        None => false,
    }
}

fn value_to_string_into(v: &Value, out: &mut String) {
    match v {
        Value::String(s) => out.push_str(s),
        Value::Bool(true) => out.push_str("true"),
        Value::Bool(false) => out.push_str("false"),
        Value::Number(n) => out.push_str(&n.to_string()),
        Value::Null => {}
        other => out.push_str(&other.to_string()),
    }
}

fn resolve_path<'a>(path: &[String], ctx_stack: &'a ContextStack) -> Option<&'a Value> {
    let (first, rest) = path.split_first()?;
    let mut value = ctx_stack.get(first)?;

    for key in rest {
        value = match value {
            Value::Object(map) => map.get(key)?,
            Value::Array(arr) => arr.get(key.parse::<usize>().ok()?)?,
            _ => return None,
        };
    }

    Some(value)
}