Skip to main content

somni_template/
lib.rs

1//! # somni-template
2//!
3//! A small, configurable templating engine built on top of [`somni-expr`](somni_expr).
4//!
5//! Templates are **transpiled** into a Somni program and then executed. Literal text is
6//! emitted verbatim; interpolations and directive expressions are evaluated by Somni.
7//!
8//! **Language guide:** [`_template_book`].
9//!
10//! ## Example
11//!
12//! ```rust
13//! use somni_template::{Env, Iter, Syntax, Template};
14//!
15//! let tmpl = Template::compile(
16//!     "#for n in nums\n{{ str(n) }},\n#endfor\n",
17//!     &Syntax::lines(),
18//! )
19//! .unwrap();
20//!
21//! let mut env = Env::new();
22//! env.value("nums", Iter(vec![1u64, 2, 3]));
23//!
24//! assert_eq!(tmpl.render(env).unwrap(), "1,\n2,\n3,\n");
25//! ```
26//!
27//! See [`Env`] for supplying data, [`IntoValue`]/[`Iter`] for values and loop sources, and
28//! [`TemplateError`] for diagnostics (which always point into the original template).
29
30#![warn(missing_docs)]
31
32pub mod _template_book;
33pub mod error;
34pub mod syntax;
35
36mod env;
37mod parse;
38mod resolve;
39mod scan;
40mod transpile;
41mod value;
42
43use std::{cell::RefCell, marker::PhantomData, rc::Rc};
44
45use somni_expr::{Context, ExpressionVisitor, TypeSet};
46use somni_parser::{Location, ast::Item};
47
48pub use env::Env;
49pub use error::TemplateError;
50pub use somni_expr::{SomniIterator, SomniStruct, TypedValue};
51pub use syntax::{BlockStyle, Syntax, resolve_syntax, split_frontmatter};
52pub use value::{IntoValue, Iter, TemplateTypes};
53
54use transpile::{EMIT_FN, EMIT_LIT_FN, RENDER_FN, Transpiled};
55
56/// Shared data used by the internal `emit_lit` function to emit literal chunks by index.
57struct Literals {
58    template: String,
59    spans: Vec<Location>,
60}
61
62/// A compiled template.
63///
64/// Compilation is independent of data and reusable across many renders. See
65/// [`Template::compile`] and [`Template::render`].
66#[derive(Clone, Debug)]
67pub struct Template {
68    template: String,
69    transpiled: Transpiled,
70}
71
72impl Template {
73    /// Compiles a template from source using the given [`Syntax`].
74    ///
75    /// Equivalent to [`Template::compile_with`] with a loader that rejects every include.
76    ///
77    /// If `source` begins with a `---`-fenced frontmatter block, those settings overlay
78    /// `syntax` (frontmatter takes precedence for keys it sets) and only the body after the
79    /// closing fence is compiled. See [`Syntax::with_frontmatter`].
80    ///
81    /// Returns a [`TemplateError`] (pointing into the original `source`, including any
82    /// frontmatter) on malformed directives or expressions.
83    pub fn compile(source: &str, syntax: &Syntax) -> Result<Template, TemplateError> {
84        Self::compile_with(source, syntax, &mut |path| {
85            Err(format!(
86                "include `{path}` requires Template::compile_with with a loader"
87            ))
88        })
89    }
90
91    /// Compiles a template, loading `include` paths through `loader`.
92    ///
93    /// `loader` is called with the path string from each `include "…"` directive. Returned
94    /// source may itself contain frontmatter and nested includes. Cycles are rejected.
95    ///
96    /// Each distinct `(path, with-parameter signature)` becomes its own Somni function
97    /// called with the listed bindings. A bare `include` (no `with`) is inlined instead and
98    /// shares the caller’s context. Host functions and Env values are visible in both forms.
99    pub fn compile_with(
100        source: &str,
101        syntax: &Syntax,
102        loader: &mut dyn FnMut(&str) -> Result<String, String>,
103    ) -> Result<Template, TemplateError> {
104        let (syntax, _body, body_offset) = resolve_syntax(source, syntax)?;
105        // Scan/parse the full source starting at `body_offset` so locations are absolute
106        // (frontmatter-aware) without a post-hoc shift.
107        let mut nodes = parse::parse(source, &syntax, body_offset)?;
108
109        let mut arena = source.to_string();
110        let modules = resolve::resolve_includes(&mut arena, &mut nodes, &syntax, loader)
111            .map_err(|e| e.with_diagnostic_source(arena.clone()))?;
112        let transpiled = transpile::transpile(&arena, &nodes, &modules);
113
114        // Validate the generated program so that expression syntax errors surface at compile
115        // time, mapped back to the original template.
116        if let Err(err) =
117            somni_parser::parser::parse::<<TemplateTypes as TypeSet>::Parser>(&transpiled.source)
118        {
119            let location = transpiled
120                .map_location(err.location)
121                .unwrap_or(Location {
122                    start: body_offset,
123                    end: body_offset,
124                });
125            return Err(TemplateError::new(
126                format!("invalid expression: {}", err.error),
127                location,
128            )
129            .with_diagnostic_source(arena));
130        }
131
132        Ok(Template {
133            template: arena,
134            transpiled,
135        })
136    }
137
138    /// Returns the generated Somni program that this template transpiles to.
139    ///
140    /// Primarily useful for debugging and snapshotting; the exact output is not a stable
141    /// part of the API.
142    pub fn generated_program(&self) -> &str {
143        &self.transpiled.source
144    }
145
146    /// Renders the template with the given (single-use) [`Env`].
147    ///
148    /// Runtime evaluation errors are mapped back to locations in the original template.
149    pub fn render(&self, env: Env) -> Result<String, TemplateError> {
150        // `names` must outlive `ctx`, so it is declared first.
151        let (names, env) = env.into_parts();
152
153        let buffer = Rc::new(RefCell::new(String::new()));
154        let literals = Rc::new(Literals {
155            template: self.template.clone(),
156            spans: self.transpiled.literals.clone(),
157        });
158
159        let mut ctx = Context::<TemplateTypes>::parse_with_types(&self.transpiled.source)
160            .expect("generated program is validated during compile");
161
162        {
163            let buffer = buffer.clone();
164            ctx.add_function(EMIT_FN, move |s: &str| {
165                buffer.borrow_mut().push_str(s);
166            });
167        }
168        {
169            let buffer = buffer.clone();
170            let literals = literals.clone();
171            ctx.add_function(EMIT_LIT_FN, move |index: u64| {
172                if let Some(span) = literals.spans.get(index as usize) {
173                    buffer
174                        .borrow_mut()
175                        .push_str(&literals.template[span.start..span.end]);
176                }
177            });
178        }
179
180        env.apply(&mut ctx, &names);
181
182        let program = somni_parser::parser::parse::<<TemplateTypes as TypeSet>::Parser>(
183            &self.transpiled.source,
184        )
185        .expect("generated program is validated during compile");
186        let render_fn = program
187            .items
188            .iter()
189            .find_map(|item| match item {
190                Item::Function(f) if f.name.source(&self.transpiled.source) == RENDER_FN => Some(f),
191                _ => None,
192            })
193            .expect("generated program always defines the render function");
194
195        let result = {
196            let mut visitor = ExpressionVisitor::<Context<'_, TemplateTypes>, TemplateTypes> {
197                context: &mut ctx,
198                source: &self.transpiled.source,
199                _marker: PhantomData,
200            };
201            visitor.visit_function(render_fn, &[])
202        };
203
204        result.map_err(|err| {
205            let location = self
206                .transpiled
207                .map_location(err.location)
208                .unwrap_or(Location { start: 0, end: 0 });
209            TemplateError::new(err.message, location)
210                .with_diagnostic_source(self.template.clone())
211        })?;
212
213        drop(ctx);
214
215        let output = Rc::try_unwrap(buffer)
216            .map(RefCell::into_inner)
217            .unwrap_or_else(|rc| rc.borrow().clone());
218        Ok(output)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn compile_rejects_include_without_loader() {
228        let err = Template::compile(r#"{% include "x.tmpl" %}"#, &Syntax::brackets()).unwrap_err();
229        assert!(
230            err.message.contains("compile_with") || err.message.contains("loader"),
231            "unexpected: {}",
232            err.message
233        );
234    }
235}