Skip to main content

carta_core/template/
render.rs

1//! Rendering a parsed [`Template`] against a [`Value`] context.
2//!
3//! ## Indentation
4//!
5//! When a variable, partial, or loop-item value spans multiple lines and the current output line so
6//! far is entirely ASCII spaces, those spaces become the indent prefixed to every continuation line
7//! of the value. A line prefix containing anything else (a tab, any non-space character) suppresses
8//! the indent. Literal template text is always emitted verbatim.
9
10use std::borrow::Cow;
11use std::cell::RefCell;
12use std::collections::BTreeMap;
13use std::rc::Rc;
14
15use super::node::{Expr, Node, Template};
16use super::pipe;
17use super::{TemplateError, Value};
18
19/// Guards against unbounded partial recursion (a partial that includes itself).
20const MAX_DEPTH: usize = 64;
21
22/// A loop binding: the current element, and the bare name it is reachable under (besides `$it$`).
23struct Scope {
24    bind: Option<String>,
25    value: Value,
26}
27
28/// A parsed-partial cache layered over the caller's name→source resolver. A partial referenced more
29/// than once — across the template or from inside a loop body — is read and parsed a single time,
30/// and later references reuse the parsed tree. A name that resolves to no source is remembered as
31/// absent.
32struct Partials<'a> {
33    resolve: &'a dyn Fn(&str) -> Option<String>,
34    cache: RefCell<BTreeMap<String, Option<Rc<Template>>>>,
35}
36
37impl<'a> Partials<'a> {
38    fn new(resolve: &'a dyn Fn(&str) -> Option<String>) -> Self {
39        Self {
40            resolve,
41            cache: RefCell::new(BTreeMap::new()),
42        }
43    }
44
45    /// The parsed partial named `name`, or `None` when no source resolves for it. The parsed tree is
46    /// cached so repeated references parse the source once; a parse error in the source propagates.
47    fn get(&self, name: &str) -> Result<Option<Rc<Template>>, TemplateError> {
48        if let Some(cached) = self.cache.borrow().get(name) {
49            return Ok(cached.clone());
50        }
51        let parsed = match (self.resolve)(name) {
52            // A partial drops a single trailing newline from its source, so the line a `$name()$`
53            // sits on is not forced open by the partial file's own final newline.
54            Some(source) => Some(Rc::new(Template::parse(
55                source.strip_suffix('\n').unwrap_or(&source),
56            )?)),
57            None => None,
58        };
59        self.cache
60            .borrow_mut()
61            .insert(name.to_owned(), parsed.clone());
62        Ok(parsed)
63    }
64}
65
66/// The growing output, plus a one-bit memory of how the last text reached it.
67///
68/// A block-level value (a rendered body or block metadata) carries a trailing blank line. When such
69/// a value sits on its own line in the template — `$body$` followed by a newline — that line's own
70/// break would stack onto the value's trailing blank line and open an extra empty line. So a value
71/// that ends in a newline absorbs every newline that immediately follows it: its own trailing blank
72/// line stands, and the line break (or blank lines) written after it in the template are dropped.
73/// Literal template text never arms this, so blank lines an author writes between literals are
74/// preserved exactly.
75#[derive(Default)]
76struct Sink {
77    buf: String,
78    absorb_newline: bool,
79}
80
81impl Sink {
82    /// Append literal template text verbatim, save that a preceding value's trailing newline first
83    /// swallows any leading newlines of this text.
84    fn push_literal(&mut self, text: &str) {
85        let text = self.take_absorbed(text);
86        self.buf.push_str(text);
87        self.absorb_newline = false;
88    }
89
90    /// Append an interpolated value, indenting its continuation lines to a space-only current-line
91    /// prefix. A preceding value's trailing newline swallows any leading newlines of this value; a
92    /// value that ends in a newline arms the same rule for whatever follows.
93    fn push_value(&mut self, text: &str) {
94        let text = self.take_absorbed(text);
95        let ends_with_newline = text.ends_with('\n');
96        let indent = self.current_indent();
97        if indent == 0 || !text.contains('\n') {
98            self.buf.push_str(text);
99        } else {
100            let pad = " ".repeat(indent);
101            let mut lines = text.split('\n');
102            if let Some(first) = lines.next() {
103                self.buf.push_str(first);
104            }
105            for line in lines {
106                self.buf.push('\n');
107                // A blank line stays blank: indenting it would leave trailing spaces on an otherwise
108                // empty line, so the prefix is applied only to lines that carry content.
109                if !line.is_empty() {
110                    self.buf.push_str(&pad);
111                    self.buf.push_str(line);
112                }
113            }
114        }
115        self.absorb_newline = ends_with_newline;
116    }
117
118    /// Drop every leading newline from `text` when a preceding value armed the rule.
119    fn take_absorbed<'a>(&self, text: &'a str) -> &'a str {
120        if self.absorb_newline {
121            text.trim_start_matches('\n')
122        } else {
123            text
124        }
125    }
126
127    /// The indentation to apply to a value's continuation lines: the current line's width when it is
128    /// all spaces, else zero.
129    fn current_indent(&self) -> usize {
130        let line = match self.buf.rfind('\n') {
131            Some(k) => self.buf.get(k + 1..).unwrap_or(""),
132            None => self.buf.as_str(),
133        };
134        if !line.is_empty() && line.bytes().all(|b| b == b' ') {
135            line.len()
136        } else {
137            0
138        }
139    }
140}
141
142impl Template {
143    /// Render the template against `context`. `resolve_partial` maps a partial name to its source
144    /// text; pass a closure returning `None` for templates that use no partials.
145    ///
146    /// # Errors
147    /// [`TemplateError`] when a referenced partial cannot be resolved (`resolve_partial` returns
148    /// `None` for a name the template actually uses).
149    pub fn render(
150        &self,
151        context: &Value,
152        resolve_partial: &dyn Fn(&str) -> Option<String>,
153    ) -> Result<String, TemplateError> {
154        let partials = Partials::new(resolve_partial);
155        let mut sink = Sink::default();
156        let mut scopes = Vec::new();
157        render_nodes(&self.nodes, context, &mut scopes, &partials, 0, &mut sink)?;
158        Ok(sink.buf)
159    }
160}
161
162fn render_nodes(
163    nodes: &[Node],
164    ctx: &Value,
165    scopes: &mut Vec<Scope>,
166    partials: &Partials<'_>,
167    depth: usize,
168    out: &mut Sink,
169) -> Result<(), TemplateError> {
170    for node in nodes {
171        render_node(node, ctx, scopes, partials, depth, out)?;
172    }
173    Ok(())
174}
175
176fn render_node(
177    node: &Node,
178    ctx: &Value,
179    scopes: &mut Vec<Scope>,
180    partials: &Partials<'_>,
181    depth: usize,
182    out: &mut Sink,
183) -> Result<(), TemplateError> {
184    match node {
185        Node::Literal(text) => out.push_literal(text),
186        Node::Var(expr) => {
187            if let Some(value) = eval(expr, ctx, scopes) {
188                out.push_value(&pipe::stringify(&value));
189            } else if !expr.pipes.is_empty() {
190                // An absent path is an empty value; its pipe chain still applies, so `$x/length$`
191                // on a missing `x` yields `0` rather than vanishing.
192                let mut value = Value::Str(String::new());
193                for filter in &expr.pipes {
194                    value = pipe::apply(&value, filter);
195                }
196                out.push_value(&pipe::stringify(&value));
197            }
198        }
199        Node::If {
200            branches,
201            otherwise,
202        } => {
203            for (cond, body) in branches {
204                if eval(cond, ctx, scopes)
205                    .as_deref()
206                    .is_some_and(Value::is_truthy)
207                {
208                    return render_nodes(body, ctx, scopes, partials, depth, out);
209                }
210            }
211            render_nodes(otherwise, ctx, scopes, partials, depth, out)?;
212        }
213        Node::For {
214            expr,
215            bind,
216            body,
217            sep,
218        } => render_for(
219            expr,
220            bind.as_ref(),
221            body,
222            sep,
223            ctx,
224            scopes,
225            partials,
226            depth,
227            out,
228        )?,
229        Node::Partial {
230            name,
231            map_over,
232            sep,
233        } => render_partial(
234            name,
235            map_over.as_ref(),
236            sep.as_ref(),
237            ctx,
238            scopes,
239            partials,
240            depth,
241            out,
242        )?,
243    }
244    Ok(())
245}
246
247#[allow(clippy::too_many_arguments)]
248fn render_for(
249    expr: &Expr,
250    bind: Option<&String>,
251    body: &[Node],
252    sep: &[Node],
253    ctx: &Value,
254    scopes: &mut Vec<Scope>,
255    partials: &Partials<'_>,
256    depth: usize,
257    out: &mut Sink,
258) -> Result<(), TemplateError> {
259    let Some(items) = eval(expr, ctx, scopes).map(into_items) else {
260        return Ok(());
261    };
262    for (i, item) in items.into_iter().enumerate() {
263        if i > 0 {
264            render_nodes(sep, ctx, scopes, partials, depth, out)?;
265        }
266        scopes.push(Scope {
267            bind: bind.cloned(),
268            value: item,
269        });
270        let result = render_nodes(body, ctx, scopes, partials, depth, out);
271        scopes.pop();
272        result?;
273    }
274    Ok(())
275}
276
277#[allow(clippy::too_many_arguments)]
278fn render_partial(
279    name: &str,
280    map_over: Option<&Expr>,
281    sep: Option<&String>,
282    ctx: &Value,
283    scopes: &mut Vec<Scope>,
284    partials: &Partials<'_>,
285    depth: usize,
286    out: &mut Sink,
287) -> Result<(), TemplateError> {
288    if depth >= MAX_DEPTH {
289        return Ok(());
290    }
291    let Some(template) = partials.get(name)? else {
292        return Err(TemplateError::new(format!(
293            "partial `{name}` could not be found"
294        )));
295    };
296    match map_over {
297        None => {
298            let rendered = render_to_string(&template.nodes, ctx, scopes, partials, depth + 1)?;
299            out.push_value(&rendered);
300        }
301        Some(expr) => {
302            let Some(items) = eval(expr, ctx, scopes).map(into_items) else {
303                return Ok(());
304            };
305            let separator = sep.cloned().unwrap_or_default();
306            let mut pieces = Vec::new();
307            for item in items {
308                scopes.push(Scope {
309                    bind: None,
310                    value: item,
311                });
312                let result = render_to_string(&template.nodes, ctx, scopes, partials, depth + 1);
313                scopes.pop();
314                pieces.push(result?);
315            }
316            out.push_value(&pieces.join(&separator));
317        }
318    }
319    Ok(())
320}
321
322/// Render `nodes` into an independent string, used for a partial's body before it is interpolated as
323/// a single value into the surrounding output.
324fn render_to_string(
325    nodes: &[Node],
326    ctx: &Value,
327    scopes: &mut Vec<Scope>,
328    partials: &Partials<'_>,
329    depth: usize,
330) -> Result<String, TemplateError> {
331    let mut sink = Sink::default();
332    render_nodes(nodes, ctx, scopes, partials, depth, &mut sink)?;
333    Ok(sink.buf)
334}
335
336/// A scalar or map iterates as a single element; a list iterates its elements.
337fn into_items(value: Cow<'_, Value>) -> Vec<Value> {
338    match value.into_owned() {
339        Value::List(items) => items,
340        other => vec![other],
341    }
342}
343
344/// Resolve an expression to a value, applying its pipes. `None` means the path is absent.
345fn eval<'a>(expr: &Expr, ctx: &'a Value, scopes: &'a [Scope]) -> Option<Cow<'a, Value>> {
346    let base = lookup(&expr.path, ctx, scopes)?;
347    if expr.pipes.is_empty() {
348        return Some(Cow::Borrowed(base));
349    }
350    let mut value = Cow::Borrowed(base);
351    for filter in &expr.pipes {
352        value = Cow::Owned(pipe::apply(value.as_ref(), filter));
353    }
354    Some(value)
355}
356
357/// Walk a dotted path. The head segment resolves against loop scopes (`it`, then bound names) before
358/// the root context; the rest descends through maps.
359fn lookup<'a>(path: &[String], ctx: &'a Value, scopes: &'a [Scope]) -> Option<&'a Value> {
360    let (head, rest) = path.split_first()?;
361    let base = if head == "it"
362        && let Some(scope) = scopes.last()
363    {
364        &scope.value
365    } else if let Some(scope) = scopes
366        .iter()
367        .rev()
368        .find(|s| s.bind.as_deref() == Some(head.as_str()))
369    {
370        &scope.value
371    } else if let Value::Map(map) = ctx {
372        map.get(head)?
373    } else {
374        return None;
375    };
376    let mut current = base;
377    for segment in rest {
378        match current {
379            Value::Map(map) => current = map.get(segment)?,
380            _ => return None,
381        }
382    }
383    Some(current)
384}