Skip to main content

template/
template.rs

1#![no_std]
2
3//! A fast, safe template engine for HTML and text rendering, inspired by Jinja2
4//! with `no_std`support.
5//!
6//! `template` provides a runtime template engine that compiles templates into
7//! an AST and renders them via a tree-walking interpreter. It supports both
8//! HTML mode (with automatic escaping) and text mode (no escaping).
9//!
10//! # Quick start
11//!
12//! ```rust
13//! use template::{Engine, EscapeMode, context};
14//!
15//! let mut engine = Engine::new(EscapeMode::Html);
16//! engine.add_template("hello", "<p>Hello, {{ name }}!</p>");
17//!
18//! let result = engine.render("hello", context! { name: "World" });
19//! assert_eq!(result.unwrap(), "<p>Hello, World!</p>");
20//! ```
21//!
22//! The [`context!`] macro builds a context map.
23//! You can also pass any `#[derive(Serialize)]` struct (requires the `std`
24//! feature), using `&` to pass a reference: `engine.render("t", &my_struct)`.
25//!
26//! # Features
27//!
28//! | Feature    | Description                                                                 |
29//! |------------|-----------------------------------------------------------------------------|
30//! | `default`  | Enables the `std` feature.                                                  |
31//! | `std`      | Enables `std`-dependent features such as `std::error::Error` on error types, `serde` integration, and the ability to pass `#[derive(Serialize)]` structs directly to [`Engine::render`]. |
32//!
33//! # Working with slices and vectors
34//!
35//! Iterate over a list with `{% for %}`:
36//!
37//! ```rust
38//! use template::{Engine, EscapeMode, context};
39//!
40//! let mut engine = Engine::new(EscapeMode::Text);
41//! engine.add_template("list", "{% for item in items %}- {{ item }}
42//! {% endfor %}").unwrap();
43//!
44//! let result = engine.render("list", context! {
45//!     items: vec!["apple", "banana", "cherry"],
46//! }).unwrap();
47//! assert_eq!(result, "- apple\n- banana\n- cherry\n");
48//! ```
49//!
50//! Access elements by index, including nested fields:
51//!
52//! ```rust
53//! use template::{Engine, EscapeMode, context};
54//!
55//! let mut engine = Engine::new(EscapeMode::Text);
56//! engine.add_template("t", "{{ users[0].name }}, {{ users[1].name }}").unwrap();
57//!
58//! let result = engine.render("t", context! {
59//!     users: vec![
60//!         context! { name: "Alice", age: 30 },
61//!         context! { name: "Bob", age: 25 },
62//!     ],
63//! }).unwrap();
64//! assert_eq!(result, "Alice, Bob");
65//! ```
66//!
67//! Use filters on arrays — `join`, `first`, `last`, `length`, `reverse`:
68//!
69//! ```rust
70//! use template::{Engine, EscapeMode, context};
71//!
72//! let mut engine = Engine::new(EscapeMode::Text);
73//! engine.add_template("t", "\
74//! join: {{ items | join(\", \") }}
75//! first: {{ items | first }}
76//! last:  {{ items | last }}
77//! count: {{ items | length }}
78//! rev:   {{ items | reverse | join(\", \") }}
79//! ").unwrap();
80//!
81//! let result = engine.render("t", context! {
82//!     items: vec!["a", "b", "c"],
83//! }).unwrap();
84//! assert_eq!(result, "\
85//! join: a, b, c
86//! first: a
87//! last:  c
88//! count: 3
89//! rev:   c, b, a
90//! ");
91//! ```
92//!
93//! Check membership with the `in` operator:
94//!
95//! ```rust
96//! use template::{Engine, EscapeMode, context};
97//!
98//! let mut engine = Engine::new(EscapeMode::Text);
99//! engine.add_template("t",
100//!     "{% if \"admin\" in roles %}Welcome, admin!{% endif %}"
101//! ).unwrap();
102//!
103//! let result = engine.render("t", context! {
104//!     roles: vec!["user", "admin", "moderator"],
105//! }).unwrap();
106//! assert_eq!(result, "Welcome, admin!");
107//! ```
108//!
109//! Render directly from a Rust `Vec`:
110//!
111//! ```rust
112//! use template::{Engine, EscapeMode, context};
113//!
114//! let mut engine = Engine::new(EscapeMode::Text);
115//! engine.add_template("t", "{% for n in numbers %}{{ n }} {% endfor %}").unwrap();
116//!
117//! let result = engine.render("t", context! {
118//!     numbers: vec![10, 20, 30],
119//! }).unwrap();
120//! assert_eq!(result, "10 20 30 ");
121//! ```
122//!
123//! Filter across a nested array field:
124//!
125//! ```rust
126//! use template::{Engine, EscapeMode, context};
127//!
128//! let mut engine = Engine::new(EscapeMode::Text);
129//! engine.add_template("t",
130//!     "{% for tag in post.tags %}{{ tag | upper }} {% endfor %}"
131//! ).unwrap();
132//!
133//! let result = engine.render("t", context! {
134//!     post: context! {
135//!         title: "Hello",
136//!         tags: vec!["rust", "template", "dev"],
137//!     },
138//! }).unwrap();
139//! assert_eq!(result, "RUST TEMPLATE DEV ");
140//! ```
141//!
142//! # Modes
143//!
144//! - `EscapeMode::Html` — auto-escapes `{{ ... }}` output (escapes `&`, `<`, `>`, `"`, `'`)
145//! - `EscapeMode::Text` — no escaping, raw output
146//!
147//! # Template syntax
148//!
149//! | Syntax | Description |
150//! |--------|-------------|
151//! | `{{ expr }}` | Output expression value (auto-escaped in HTML mode) |
152//! | `{% if cond %}...{% elif %}...{% else %}...{% endif %}` | Conditional |
153//! | `{% for item in items %}...{% endfor %}` | Loop |
154//! | `{% include "name" %}` | Include another template |
155//! | `{% extends "base" %}` | Template inheritance |
156//! | `{% block name %}...{% endblock %}` | Overridable block |
157//! | `{{ super() }}` | Render parent's block content (only inside `{% block %}`) |
158//! | `{% set var = expr %}` | Assign a variable |
159//! | `{% raw %}...{% endraw %}` | Raw text (no parsing; can contain `{%` sequences) |
160//! | `{# comment #}` | Comment (ignored) |
161//! | `expr \| filter_name` | Apply a filter |
162//!
163//! # Expressions
164//!
165//! - Variable access: `name`, `user.email`, `items[0]`
166//! - String literals: `"hello"`, `'world'`
167//! - Number literals: `42`, `3.14` (scientific notation and hex are not supported)
168//! - Boolean: `true`, `false`
169//! - Comparisons: `==`, `!=`, `<`, `>`, `<=`, `>=` (floats follow IEEE 754; `NaN` is falsy and `NaN` compared to anything is `false`)
170//! - Logical: `and`, `or`, `not`
171//! - Arithmetic: `+`, `-`, `*`, `/`, `%` (dividing or modulo by zero returns an error)
172//! - Containment: `item in list`
173//! - Grouping: `(expr)`
174//! - Filters: `expr | filter_name`, `expr | filter(arg1, arg2)`
175//! - Function calls: `super()`, `range(n)`, `range(start, end)`
176//!
177//! ## Safety boundaries
178//!
179//! - **Integer division/modulo by zero** is rejected with a render error (not a panic).
180//!   Float division/modulo by zero follows IEEE 754 (returns `inf` / `-inf` / `NaN`).
181//! - **Circular includes** (`a -> b -> a`) are detected by a depth limit (64).
182//! - **Circular extends** (`a extends b extends a`) are detected by a depth limit (128).
183//! - **Unknown functions** (`{{ myfunc() }}`) return a render error.
184//!
185//! # Built-in filters
186//!
187//! | Filter | Description |
188//! |--------|-------------|
189//! | `upper` | Convert to uppercase |
190//! | `lower` | Convert to lowercase |
191//! | `trim` | Trim leading/trailing whitespace |
192//! | `escape` | HTML-escape the value (`Safe` result, no double-escape) |
193//! | `safe` | Mark a string as safe (bypasses auto-escaping) |
194//! | `length` | Length of string (character count), array, or map |
195//! | `default(val)` | Return `val` if the input is falsy (i.e. `false`, `0`, `0.0`, `NaN`, `""`, `[]`, `null`) |
196//! | `capitalize` | Uppercase first character, lowercase the rest |
197//! | `title` | Title case (capitalize each word) |
198//! | `join(sep)` | Join array elements with separator |
199//! | `reverse` | Reverse a string (by Unicode scalar value) or array |
200//! | `first` | First element of an array or first character of a string |
201//! | `last` | Last element of an array or last character of a string |
202//! | `urlencode` | URL-encode (form-style, `+` for spaces) |
203//!
204//! # Error behavior
205//!
206//! - `add_template` returns an error if a template with the same name already exists.
207//! - Unknown filter names (`{{ x | unknown }}`) produce a parse-time error.
208//! - Exceeding the include depth (64) or extend depth (128) returns a render error.
209//!
210//! # Architecture
211//!
212//! ```text
213//!               ┌──────────────┐
214//!  add_template │              │
215//!  ────────────▶│   PARSER     │
216//!   (source)    │  (recursive  │
217//!               │   descent    │
218//!               │   parser)    │
219//!               └──────┬───────┘
220//!                      │ AST
221//!               ┌──────▼───────┐
222//!               │   ENGINE     │
223//!               │  (template   │
224//!               │   cache)     │
225//!               └──────┬───────┘
226//!                      │ render(name, ctx)
227//!               ┌──────▼───────┐
228//!               │  RENDERER    │
229//!               │  (tree-walk  │
230//!               │   VM with    │
231//!               │   extends /  │
232//!               │   blocks /   │
233//!               │   includes   │
234//!               │   resolution)│
235//!               └──────┬───────┘
236//!                      │ output
237//!               ┌──────▼───────┐
238//!               │  fmt::Write  │
239//!               │  (String,    │
240//!               │   Vec<u8>,   │
241//!               │   io::Write) │
242//!               └──────────────┘
243//! ```
244
245extern crate alloc;
246
247#[cfg(feature = "std")]
248extern crate std;
249
250mod ast;
251mod context;
252mod engine;
253mod error;
254mod escapers;
255mod expr;
256mod filters;
257mod parser;
258mod value;
259mod vm;
260
261pub use context::{Context, IntoContext};
262pub use engine::{Engine, EscapeMode};
263pub use error::{Error, SerdeError};
264
265#[doc(hidden)]
266pub mod __macro_support {
267    pub use alloc::{collections::BTreeMap, rc::Rc, string::String};
268    pub use core::convert::Into;
269
270    pub fn into_value<T: Into<crate::value::Value>>(v: T) -> crate::value::Value {
271        v.into()
272    }
273
274    pub fn build_context(map: BTreeMap<String, crate::value::Value>) -> crate::context::Context {
275        crate::context::Context(crate::value::Value::Map(Rc::new(map)))
276    }
277}
278
279/// Build a context map for [`Engine::render`] without requiring `Serialize`.
280///
281/// Unquoted identifiers (e.g. `name`) are stringified automatically.
282/// Quoted string keys (e.g. `"my-key"`) are also accepted for programmatic
283/// construction from external data.
284///
285/// ```rust
286/// use template::{Engine, EscapeMode, context};
287///
288/// let mut engine = Engine::new(EscapeMode::Text);
289/// engine.add_template("t", "Hello, {{ name }}!").unwrap();
290///
291/// let result = engine.render("t", context! { name: "World" }).unwrap();
292/// assert_eq!(result, "Hello, World!");
293/// ```
294///
295/// Supports nesting, vectors, and all types that implement [`Into<Value>`]:
296///
297/// ```rust
298/// use template::{Engine, EscapeMode, context};
299///
300/// let mut engine = Engine::new(EscapeMode::Text);
301/// engine.add_template("t", "\
302/// {% for item in items %}- {{ item }}
303/// {% endfor %}").unwrap();
304///
305/// let result = engine.render("t", context! {
306///     items: vec!["apple", "banana"],
307/// }).unwrap();
308/// assert_eq!(result, "- apple\n- banana\n");
309/// ```
310#[macro_export]
311macro_rules! context {
312    (@key $key:ident) => { stringify!($key) };
313    (@key $key:expr) => { $key };
314
315    ($($key:tt : $value:expr),* $(,)?) => {{
316        let mut __map = $crate::__macro_support::BTreeMap::new();
317        $(
318            __map.insert(
319                $crate::context!(@key $key).to_string(),
320                $crate::__macro_support::into_value($value),
321            );
322        )*
323        $crate::__macro_support::build_context(__map)
324    }};
325}