Skip to main content

template/
template.rs

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