somni-template 0.4.0

A configurable templating engine built on the Somni expression language
Documentation
//! Transpilation of a parsed template tree into a Somni program.
//!
//! The generated program defines one render function ([`RENDER_FN`]) plus one function per
//! resolved include module. Bodies drive an output buffer through two internal functions:
//!
//! - [`EMIT_FN`]`(s)` appends a string produced by an interpolation, and
//! - [`EMIT_LIT_FN`]`(i)` appends the `i`-th literal chunk (looked up by span into the
//!   shared source arena — literal text is never embedded in the generated source).
//!
//! Every expression copied verbatim from the template (interpolations, conditions, loop
//! iterables and headers) records a [`MapEntry`] so that evaluation errors — whose locations
//! point into the generated source — can be translated back into the template precisely.

use somni_parser::Location;

use crate::{
    parse::{Arm, Node},
    resolve::ResolvedModule,
};

/// Name of the generated render function.
pub const RENDER_FN: &str = "__tmpl_render";
/// Name of the internal string-emitting function.
pub const EMIT_FN: &str = "__tmpl_emit";
/// Name of the internal literal-chunk-emitting function.
pub const EMIT_LIT_FN: &str = "__tmpl_emit_lit";

/// A mapping from a range in the generated source back to a range in the template.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MapEntry {
    /// Start offset in the generated source.
    pub gen_start: usize,
    /// Start offset in the original template.
    pub tmpl_start: usize,
    /// Length of the copied region (identical in both).
    pub len: usize,
}

/// The result of transpiling a template.
#[derive(Clone, Debug)]
pub struct Transpiled {
    /// The generated Somni source.
    pub source: String,
    /// Literal chunk spans into the *source arena*, indexed by chunk id.
    pub literals: Vec<Location>,
    /// Source map entries for verbatim-copied expression regions.
    pub map: Vec<MapEntry>,
}

impl Transpiled {
    /// Translates a location in the generated source back into the template.
    ///
    /// Returns `None` if the location does not fall within any verbatim-copied region
    /// (e.g. it points at generated scaffolding).
    pub fn map_location(&self, loc: Location) -> Option<Location> {
        let start = self.map_offset(loc.start)?;
        // Map the end using the same entry if possible; otherwise fall back to start.
        let end = self.map_offset(loc.end).unwrap_or(start);
        Some(Location {
            start,
            end: end.max(start),
        })
    }

    fn map_offset(&self, off: usize) -> Option<usize> {
        for e in &self.map {
            if off >= e.gen_start && off <= e.gen_start + e.len {
                return Some(e.tmpl_start + (off - e.gen_start));
            }
        }
        None
    }
}

struct Writer<'a> {
    template: &'a str,
    modules: &'a [ResolvedModule],
    out: String,
    literals: Vec<Location>,
    map: Vec<MapEntry>,
}

impl Writer<'_> {
    fn push(&mut self, s: &str) {
        self.out.push_str(s);
    }

    /// Copies a template region verbatim into the output, recording a source-map entry.
    fn push_verbatim(&mut self, loc: Location) {
        let text = loc.extract(self.template);
        self.map.push(MapEntry {
            gen_start: self.out.len(),
            tmpl_start: loc.start,
            len: text.len(),
        });
        self.out.push_str(text);
    }

    fn emit_literal(&mut self, span: Location) {
        if span.start >= span.end {
            return;
        }
        let index = self.literals.len();
        self.literals.push(span);
        self.push(&format!("{EMIT_LIT_FN}({index});\n"));
    }

    fn emit_expr(&mut self, expr: Location) {
        self.push(&format!("{EMIT_FN}("));
        self.push_verbatim(expr);
        self.push(");\n");
    }

    /// Emits `span` as alternating prefix / `with_expr` / suffix chunks around each
    /// occurrence of `literal` (left-to-right, non-overlapping).
    fn emit_text_with_replace(&mut self, span: Location, literal: &str, with_expr: Location) {
        let text = span.extract(self.template);
        let mut start = 0;
        while let Some(found) = text[start..].find(literal) {
            let abs = start + found;
            if found > 0 {
                self.emit_literal(Location {
                    start: span.start + start,
                    end: span.start + abs,
                });
            }
            self.emit_expr(with_expr);
            start = abs + literal.len();
        }
        if start < text.len() {
            self.emit_literal(Location {
                start: span.start + start,
                end: span.end,
            });
        } else if start == 0 {
            // No occurrence: emit the whole span unchanged.
            self.emit_literal(span);
        }
    }

    fn nodes(&mut self, nodes: &[Node], replace: Option<(&str, Location)>) {
        for node in nodes {
            self.node(node, replace);
        }
    }

    fn node(&mut self, node: &Node, replace: Option<(&str, Location)>) {
        match node {
            Node::Text(span) => match replace {
                Some((literal, with_expr)) => {
                    self.emit_text_with_replace(*span, literal, with_expr)
                }
                None => self.emit_literal(*span),
            },
            Node::Interp(expr) => self.emit_expr(*expr),
            Node::If { arms, otherwise } => {
                self.if_chain(arms, otherwise.as_deref(), replace)
            }
            Node::For {
                var,
                ty,
                iterable,
                body,
            } => {
                self.push("for ");
                self.push_verbatim(*var);
                if let Some(ty) = ty {
                    self.push(": ");
                    self.push_verbatim(*ty);
                }
                self.push(" in (");
                self.push_verbatim(*iterable);
                self.push(") {\n");
                self.nodes(body, replace);
                self.push("}\n");
            }
            Node::Replace {
                literal,
                with_expr,
                body,
            } => {
                // Nested `replace` applies its own literal; an outer replacement does not
                // rewrite through it.
                self.nodes(body, Some((literal, *with_expr)));
            }
            Node::Include { args, module_id, .. } => {
                let id = module_id.expect("includes must be resolved before transpile");
                let module = &self.modules[id];
                self.push(&module.fn_name);
                self.push("(");
                for (i, arg) in args.iter().enumerate() {
                    if i > 0 {
                        self.push(", ");
                    }
                    self.push_verbatim(arg.value);
                }
                self.push(");\n");
            }
        }
    }

    fn if_chain(
        &mut self,
        arms: &[Arm],
        otherwise: Option<&[Node]>,
        replace: Option<(&str, Location)>,
    ) {
        let (first, rest) = arms.split_first().expect("if always has >= 1 arm");

        self.push("if (");
        self.push_verbatim(first.cond);
        self.push(") {\n");
        self.nodes(&first.body, replace);
        self.push("}");

        if rest.is_empty() {
            match otherwise {
                Some(body) => {
                    self.push(" else {\n");
                    self.nodes(body, replace);
                    self.push("}\n");
                }
                None => self.push("\n"),
            }
        } else {
            self.push(" else {\n");
            self.if_chain(rest, otherwise, replace);
            self.push("}\n");
        }
    }

    fn emit_module(&mut self, module: &ResolvedModule) {
        self.push(&format!("fn {}(", module.fn_name));
        for (i, (name, ty)) in module.params.iter().enumerate() {
            if i > 0 {
                self.push(", ");
            }
            self.push(name);
            self.push(": ");
            self.push(ty);
        }
        self.push(") {\n");
        self.nodes(&module.nodes, None);
        self.push("}\n");
    }
}

/// Transpiles a parsed template (and any resolved include modules) into a Somni program.
pub fn transpile(template: &str, nodes: &[Node], modules: &[ResolvedModule]) -> Transpiled {
    let mut w = Writer {
        template,
        modules,
        out: String::new(),
        literals: Vec::new(),
        map: Vec::new(),
    };

    for module in modules {
        w.emit_module(module);
    }

    w.push(&format!("fn {RENDER_FN}() {{\n"));
    w.nodes(nodes, None);
    w.push("}\n");

    Transpiled {
        source: w.out,
        literals: w.literals,
        map: w.map,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{parse::parse, syntax::Syntax};

    fn transpile_str(src: &str, syntax: &Syntax) -> Transpiled {
        let nodes = parse(src, syntax, 0).unwrap();
        transpile(src, &nodes, &[])
    }

    #[test]
    fn text_becomes_emit_lit() {
        let t = transpile_str("hello", &Syntax::brackets());
        assert!(t.source.contains(&format!("{EMIT_LIT_FN}(0)")));
        assert_eq!(t.literals.len(), 1);
        assert_eq!(t.literals[0].extract("hello"), "hello");
    }

    #[test]
    fn interp_becomes_emit_verbatim() {
        let src = "{{ str(age) }}";
        let t = transpile_str(src, &Syntax::brackets());
        assert!(
            t.source.contains(&format!("{EMIT_FN}(str(age))")),
            "{}",
            t.source
        );
    }

    #[test]
    fn if_else_if_chain_nests() {
        let src = "{% if a %}A{% else if b %}B{% else %}C{% endif %}";
        let t = transpile_str(src, &Syntax::brackets());
        // Two `if (` and one `else {` for the else-if plus the final else.
        assert_eq!(t.source.matches("if (").count(), 2);
        assert!(t.source.contains("else {"));
    }

    #[test]
    fn for_loop_shape() {
        let src = "#for x: int in xs\n{{ str(x) }}\n#endfor\n";
        let t = transpile_str(src, &Syntax::lines());
        assert!(t.source.contains("for x: int in (xs) {"), "{}", t.source);
    }

    #[test]
    fn source_map_translates_expression_back() {
        let src = "prefix {{ boom }} suffix";
        let t = transpile_str(src, &Syntax::brackets());
        // Find where `boom` sits in the generated source.
        let gen_idx = t.source.find("boom").unwrap();
        let gen_loc = Location {
            start: gen_idx,
            end: gen_idx + "boom".len(),
        };
        let tmpl_loc = t.map_location(gen_loc).unwrap();
        assert_eq!(tmpl_loc.extract(src), "boom");
    }

    #[test]
    fn replace_becomes_prefix_expr_suffix() {
        let src = "#replace \"N\" with str(n)\n[N]\n#endreplace\n";
        let t = transpile_str(src, &Syntax::lines());
        assert!(
            t.source.contains(&format!("{EMIT_LIT_FN}(0)")),
            "{}",
            t.source
        );
        assert!(
            t.source.contains(&format!("{EMIT_FN}(str(n))")),
            "{}",
            t.source
        );
        assert!(
            t.source.contains(&format!("{EMIT_LIT_FN}(1)")),
            "{}",
            t.source
        );
        assert_eq!(t.literals[0].extract(src), "[");
        assert_eq!(t.literals[1].extract(src), "]\n");
        assert!(!t.source.contains("replace"), "{}", t.source);
    }
}