Skip to main content

rainier_view/
template.rs

1//! Parsing a template into a [`Node`] tree.
2//!
3//! A deliberately small language — the directives that carry their weight in
4//! an HTML template, and nothing else. There is no arbitrary expression
5//! evaluation and no way to call a function from a template, because a
6//! template that can compute is a template that ends up holding business
7//! logic. Anything harder than "read a value, compare it, loop over it"
8//! belongs in the controller.
9
10use rainier_support::{Error, Result};
11
12/// A parsed template.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Template {
15    /// The layout this template extends, if any.
16    pub extends: Option<String>,
17    /// The body, in order.
18    pub nodes: Vec<Node>,
19}
20
21/// One piece of a template.
22#[derive(Debug, Clone, PartialEq)]
23pub enum Node {
24    /// Literal text.
25    Text(String),
26    /// `{{ expr }}` (escaped) or `{!! expr !!}` (raw).
27    Echo {
28        /// The path to read.
29        expr: Expr,
30        /// Whether to emit the value without HTML-escaping it.
31        raw: bool,
32    },
33    /// `@if` … `@elseif` … `@else` … `@endif`.
34    If {
35        /// Each `(condition, body)` in order.
36        branches: Vec<(Condition, Vec<Node>)>,
37        /// The `@else` body, if there is one.
38        otherwise: Option<Vec<Node>>,
39    },
40    /// `@foreach(items as item)` … `@endforeach`.
41    Foreach {
42        /// The collection to walk.
43        collection: Expr,
44        /// The variable bound to each key, for `key => value` form.
45        key: Option<String>,
46        /// The variable bound to each element.
47        value: String,
48        /// The loop body.
49        body: Vec<Node>,
50    },
51    /// `@include('partial')`.
52    Include(String),
53    /// `@yield('name')` — where a layout drops a child's section.
54    Yield(String),
55    /// `@section('name')` … `@endsection`.
56    Section {
57        /// The section's name.
58        name: String,
59        /// Its content.
60        body: Vec<Node>,
61    },
62}
63
64/// A dotted path into the view data — `user.address.city`, `items.0.name`.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Expr {
67    /// The path segments.
68    pub path: Vec<String>,
69}
70
71impl Expr {
72    /// Parse a dotted path.
73    pub fn parse(source: &str) -> Self {
74        Self { path: source.trim().split('.').map(str::to_string).collect() }
75    }
76
77    /// The path as written.
78    pub fn as_string(&self) -> String {
79        self.path.join(".")
80    }
81}
82
83/// An `@if` test.
84#[derive(Debug, Clone, PartialEq)]
85pub enum Condition {
86    /// `@if(user)` — true when the value is present and truthy.
87    Truthy(Expr),
88    /// `@if(!user)`.
89    Falsy(Expr),
90    /// `@if(a == "b")` and friends.
91    Compare {
92        /// The left-hand path.
93        left: Expr,
94        /// The operator.
95        op: CompareOp,
96        /// The right-hand literal.
97        right: Literal,
98    },
99}
100
101/// A comparison operator.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum CompareOp {
104    /// `==`
105    Eq,
106    /// `!=`
107    Ne,
108    /// `>`
109    Gt,
110    /// `>=`
111    Gte,
112    /// `<`
113    Lt,
114    /// `<=`
115    Lte,
116}
117
118/// A literal on the right of a comparison.
119#[derive(Debug, Clone, PartialEq)]
120pub enum Literal {
121    /// A quoted string.
122    Text(String),
123    /// A number.
124    Number(f64),
125    /// `true` or `false`.
126    Bool(bool),
127    /// `null`.
128    Null,
129}
130
131/// One lexed piece of the source.
132#[derive(Debug, Clone, PartialEq)]
133enum Token {
134    Text(String),
135    Echo { expr: String, raw: bool },
136    Directive { name: String, arg: Option<String> },
137}
138
139/// Parse a template's source.
140pub fn parse(source: &str) -> Result<Template> {
141    let tokens = lex(source);
142    let mut cursor = 0;
143    let mut extends = None;
144
145    // `@extends` is only meaningful as the template's first directive, so it
146    // is lifted out here rather than being a node the renderer must look for.
147    if let Some(Token::Directive { name, arg }) =
148        tokens.iter().find(|token| !matches!(token, Token::Text(text) if text.trim().is_empty()))
149    {
150        if name == "extends" {
151            extends =
152                Some(arg.clone().ok_or_else(|| Error::internal("@extends needs a layout name"))?);
153        }
154    }
155
156    let nodes = parse_nodes(&tokens, &mut cursor, &[])?;
157    Ok(Template { extends, nodes })
158}
159
160/// Split the source into text, echoes and directives.
161fn lex(source: &str) -> Vec<Token> {
162    let mut tokens = Vec::new();
163    let mut text = String::new();
164    let bytes: Vec<char> = source.chars().collect();
165    let mut index = 0;
166
167    while index < bytes.len() {
168        // `{!! raw !!}` before `{{ escaped }}`: the raw opener starts with the
169        // same two braces, so checking the shorter one first would swallow it.
170        if starts_with(&bytes, index, "{!!") {
171            if let Some(end) = find_from(&bytes, index + 3, "!!}") {
172                flush(&mut tokens, &mut text);
173                tokens.push(Token::Echo { expr: collect(&bytes, index + 3, end), raw: true });
174                index = end + 3;
175                continue;
176            }
177        }
178
179        if starts_with(&bytes, index, "{{") {
180            if let Some(end) = find_from(&bytes, index + 2, "}}") {
181                flush(&mut tokens, &mut text);
182                tokens.push(Token::Echo { expr: collect(&bytes, index + 2, end), raw: false });
183                index = end + 2;
184                continue;
185            }
186        }
187
188        if bytes[index] == '@' {
189            // `@@` escapes a literal at-sign, so an email address in a
190            // template does not read as a directive.
191            if bytes.get(index + 1) == Some(&'@') {
192                text.push('@');
193                index += 2;
194                continue;
195            }
196
197            if let Some((name, arg, next)) = lex_directive(&bytes, index) {
198                flush(&mut tokens, &mut text);
199                tokens.push(Token::Directive { name, arg });
200                index = next;
201                continue;
202            }
203        }
204
205        text.push(bytes[index]);
206        index += 1;
207    }
208
209    flush(&mut tokens, &mut text);
210    tokens
211}
212
213/// Read `@name` and an optional `(argument)`.
214fn lex_directive(bytes: &[char], at: usize) -> Option<(String, Option<String>, usize)> {
215    let mut index = at + 1;
216    let mut name = String::new();
217    while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == '_') {
218        name.push(bytes[index]);
219        index += 1;
220    }
221    if name.is_empty() {
222        return None;
223    }
224
225    if bytes.get(index) != Some(&'(') {
226        return Some((name, None, index));
227    }
228
229    // Balance nested parentheses so `@if(a == "(")` survives.
230    let mut depth = 0;
231    let start = index + 1;
232    while index < bytes.len() {
233        match bytes[index] {
234            '(' => depth += 1,
235            ')' => {
236                depth -= 1;
237                if depth == 0 {
238                    let arg = collect(bytes, start, index);
239                    return Some((name, Some(arg.trim().to_string()), index + 1));
240                }
241            }
242            _ => {}
243        }
244        index += 1;
245    }
246    // Unbalanced: treat it as plain text rather than eating the rest.
247    None
248}
249
250fn starts_with(bytes: &[char], at: usize, needle: &str) -> bool {
251    needle.chars().enumerate().all(|(offset, expected)| bytes.get(at + offset) == Some(&expected))
252}
253
254fn find_from(bytes: &[char], from: usize, needle: &str) -> Option<usize> {
255    (from..bytes.len()).find(|&index| starts_with(bytes, index, needle))
256}
257
258fn collect(bytes: &[char], from: usize, to: usize) -> String {
259    bytes[from..to].iter().collect()
260}
261
262fn flush(tokens: &mut Vec<Token>, text: &mut String) {
263    if !text.is_empty() {
264        tokens.push(Token::Text(std::mem::take(text)));
265    }
266}
267
268/// Parse tokens into nodes until one of `terminators` is reached.
269fn parse_nodes(tokens: &[Token], cursor: &mut usize, terminators: &[&str]) -> Result<Vec<Node>> {
270    let mut nodes = Vec::new();
271
272    while *cursor < tokens.len() {
273        match &tokens[*cursor] {
274            Token::Text(text) => {
275                nodes.push(Node::Text(text.clone()));
276                *cursor += 1;
277            }
278            Token::Echo { expr, raw } => {
279                nodes.push(Node::Echo { expr: Expr::parse(expr), raw: *raw });
280                *cursor += 1;
281            }
282            Token::Directive { name, arg } => {
283                if terminators.contains(&name.as_str()) {
284                    return Ok(nodes);
285                }
286                match name.as_str() {
287                    // Already lifted into `Template::extends`.
288                    "extends" => *cursor += 1,
289                    "if" => nodes.push(parse_if(tokens, cursor, arg.as_deref())?),
290                    "foreach" => nodes.push(parse_foreach(tokens, cursor, arg.as_deref())?),
291                    "section" => nodes.push(parse_section(tokens, cursor, arg.as_deref())?),
292                    "include" => {
293                        nodes.push(Node::Include(unquote(require_arg(arg, "@include")?)));
294                        *cursor += 1;
295                    }
296                    "yield" => {
297                        nodes.push(Node::Yield(unquote(require_arg(arg, "@yield")?)));
298                        *cursor += 1;
299                    }
300                    other => {
301                        return Err(Error::internal(format!(
302                            "unknown template directive `@{other}`"
303                        )))
304                    }
305                }
306            }
307        }
308    }
309
310    if terminators.is_empty() {
311        Ok(nodes)
312    } else {
313        Err(Error::internal(format!(
314            "the template ended while still inside a block — expected @{}",
315            terminators.join(" or @")
316        )))
317    }
318}
319
320fn parse_if(tokens: &[Token], cursor: &mut usize, arg: Option<&str>) -> Result<Node> {
321    let mut branches = Vec::new();
322    let mut otherwise = None;
323    let mut condition = parse_condition(require_arg(&arg.map(str::to_string), "@if")?)?;
324    *cursor += 1;
325
326    loop {
327        let body = parse_nodes(tokens, cursor, &["elseif", "else", "endif"])?;
328        branches.push((condition.clone(), body));
329
330        let Some(Token::Directive { name, arg }) = tokens.get(*cursor) else {
331            return Err(Error::internal("@if was never closed with @endif"));
332        };
333
334        match name.as_str() {
335            "elseif" => {
336                condition = parse_condition(require_arg(arg, "@elseif")?)?;
337                *cursor += 1;
338            }
339            "else" => {
340                *cursor += 1;
341                otherwise = Some(parse_nodes(tokens, cursor, &["endif"])?);
342                *cursor += 1; // @endif
343                break;
344            }
345            _ => {
346                *cursor += 1; // @endif
347                break;
348            }
349        }
350    }
351
352    Ok(Node::If { branches, otherwise })
353}
354
355fn parse_foreach(tokens: &[Token], cursor: &mut usize, arg: Option<&str>) -> Result<Node> {
356    let arg = require_arg(&arg.map(str::to_string), "@foreach")?;
357    let (collection, binding) = arg.split_once(" as ").ok_or_else(|| {
358        Error::internal(format!("@foreach({arg}) should read `@foreach(items as item)`"))
359    })?;
360
361    let (key, value) = match binding.split_once("=>") {
362        Some((key, value)) => (Some(key.trim().to_string()), value.trim().to_string()),
363        None => (None, binding.trim().to_string()),
364    };
365
366    *cursor += 1;
367    let body = parse_nodes(tokens, cursor, &["endforeach"])?;
368    *cursor += 1;
369
370    Ok(Node::Foreach { collection: Expr::parse(collection), key, value, body })
371}
372
373fn parse_section(tokens: &[Token], cursor: &mut usize, arg: Option<&str>) -> Result<Node> {
374    let name = unquote(require_arg(&arg.map(str::to_string), "@section")?);
375    *cursor += 1;
376    let body = parse_nodes(tokens, cursor, &["endsection"])?;
377    *cursor += 1;
378    Ok(Node::Section { name, body })
379}
380
381fn require_arg(arg: &Option<String>, directive: &str) -> Result<String> {
382    arg.clone().ok_or_else(|| Error::internal(format!("{directive} needs an argument")))
383}
384
385fn unquote(value: String) -> String {
386    let trimmed = value.trim();
387    trimmed
388        .strip_prefix('\'')
389        .and_then(|v| v.strip_suffix('\''))
390        .or_else(|| trimmed.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
391        .unwrap_or(trimmed)
392        .to_string()
393}
394
395/// Parse an `@if` condition.
396fn parse_condition(source: String) -> Result<Condition> {
397    let source = source.trim();
398
399    for (token, op) in [
400        ("==", CompareOp::Eq),
401        ("!=", CompareOp::Ne),
402        (">=", CompareOp::Gte),
403        ("<=", CompareOp::Lte),
404        (">", CompareOp::Gt),
405        ("<", CompareOp::Lt),
406    ] {
407        if let Some((left, right)) = source.split_once(token) {
408            return Ok(Condition::Compare {
409                left: Expr::parse(left),
410                op,
411                right: parse_literal(right.trim()),
412            });
413        }
414    }
415
416    match source.strip_prefix('!') {
417        Some(rest) => Ok(Condition::Falsy(Expr::parse(rest))),
418        None => Ok(Condition::Truthy(Expr::parse(source))),
419    }
420}
421
422fn parse_literal(source: &str) -> Literal {
423    if let Some(text) = source
424        .strip_prefix('\'')
425        .and_then(|v| v.strip_suffix('\''))
426        .or_else(|| source.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
427    {
428        return Literal::Text(text.to_string());
429    }
430    match source {
431        "true" => Literal::Bool(true),
432        "false" => Literal::Bool(false),
433        "null" | "nil" => Literal::Null,
434        other => match other.parse::<f64>() {
435            Ok(number) => Literal::Number(number),
436            // An unquoted, non-numeric word reads as a string, which is what
437            // someone writing `@if(status == active)` meant.
438            Err(_) => Literal::Text(other.to_string()),
439        },
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    fn nodes(source: &str) -> Vec<Node> {
448        parse(source).expect("should parse").nodes
449    }
450
451    #[test]
452    fn plain_text_is_one_node() {
453        assert_eq!(nodes("hello"), vec![Node::Text("hello".into())]);
454    }
455
456    #[test]
457    fn interpolation_is_parsed_with_its_escaping_mode() {
458        assert_eq!(nodes("{{ name }}"), vec![Node::Echo { expr: Expr::parse("name"), raw: false }]);
459        assert_eq!(
460            nodes("{!! body !!}"),
461            vec![Node::Echo { expr: Expr::parse("body"), raw: true }]
462        );
463    }
464
465    #[test]
466    fn a_raw_echo_is_not_mistaken_for_an_escaped_one() {
467        // `{!!` and `{{` share a prefix; the longer opener must win.
468        let parsed = nodes("{!! a !!}");
469        assert!(matches!(parsed[0], Node::Echo { raw: true, .. }));
470        assert_eq!(parsed.len(), 1);
471    }
472
473    #[test]
474    fn dotted_paths_become_segments() {
475        assert_eq!(Expr::parse("user.address.city").path, vec!["user", "address", "city"]);
476    }
477
478    #[test]
479    fn text_and_interpolation_interleave() {
480        assert_eq!(
481            nodes("Hi {{ name }}!"),
482            vec![
483                Node::Text("Hi ".into()),
484                Node::Echo { expr: Expr::parse("name"), raw: false },
485                Node::Text("!".into()),
486            ]
487        );
488    }
489
490    #[test]
491    fn a_doubled_at_sign_is_a_literal() {
492        assert_eq!(nodes("ada@@example.com"), vec![Node::Text("ada@example.com".into())]);
493    }
494
495    #[test]
496    fn an_at_sign_that_is_not_a_directive_stays_text() {
497        assert_eq!(nodes("50% @ once"), vec![Node::Text("50% @ once".into())]);
498    }
499
500    #[test]
501    fn an_if_without_an_else() {
502        let parsed = nodes("@if(admin)yes@endif");
503        let Node::If { branches, otherwise } = &parsed[0] else {
504            panic!("expected an if, got {parsed:?}");
505        };
506
507        assert_eq!(branches.len(), 1);
508        assert_eq!(branches[0].0, Condition::Truthy(Expr::parse("admin")));
509        assert_eq!(branches[0].1, vec![Node::Text("yes".into())]);
510        assert!(otherwise.is_none());
511    }
512
513    #[test]
514    fn an_if_with_elseif_and_else() {
515        let parsed = nodes("@if(a)A@elseif(b)B@else C@endif");
516        let Node::If { branches, otherwise } = &parsed[0] else {
517            panic!("expected an if");
518        };
519
520        assert_eq!(branches.len(), 2);
521        assert_eq!(branches[1].0, Condition::Truthy(Expr::parse("b")));
522        assert_eq!(otherwise.as_ref().unwrap(), &vec![Node::Text(" C".into())]);
523    }
524
525    #[test]
526    fn conditions_parse_every_operator() {
527        for (source, expected) in [
528            ("a == \"x\"", CompareOp::Eq),
529            ("a != \"x\"", CompareOp::Ne),
530            ("a >= 1", CompareOp::Gte),
531            ("a <= 1", CompareOp::Lte),
532            ("a > 1", CompareOp::Gt),
533            ("a < 1", CompareOp::Lt),
534        ] {
535            let Condition::Compare { op, .. } = parse_condition(source.into()).unwrap() else {
536                panic!("expected a comparison for `{source}`");
537            };
538            assert_eq!(op, expected, "for `{source}`");
539        }
540    }
541
542    #[test]
543    fn two_character_operators_win_over_their_prefixes() {
544        // `>=` must not lex as `>` followed by `=`.
545        let Condition::Compare { op, right, .. } = parse_condition("n >= 5".into()).unwrap() else {
546            panic!("expected a comparison");
547        };
548        assert_eq!(op, CompareOp::Gte);
549        assert_eq!(right, Literal::Number(5.0));
550    }
551
552    #[test]
553    fn negation_is_recognised() {
554        assert_eq!(
555            parse_condition("!admin".into()).unwrap(),
556            Condition::Falsy(Expr::parse("admin"))
557        );
558    }
559
560    #[test]
561    fn literals_are_typed() {
562        assert_eq!(parse_literal("\"x\""), Literal::Text("x".into()));
563        assert_eq!(parse_literal("'x'"), Literal::Text("x".into()));
564        assert_eq!(parse_literal("42"), Literal::Number(42.0));
565        assert_eq!(parse_literal("true"), Literal::Bool(true));
566        assert_eq!(parse_literal("null"), Literal::Null);
567        assert_eq!(parse_literal("active"), Literal::Text("active".into()));
568    }
569
570    #[test]
571    fn a_foreach_binds_its_element() {
572        let parsed = nodes("@foreach(posts as post){{ post.title }}@endforeach");
573        let Node::Foreach { collection, key, value, body } = &parsed[0] else {
574            panic!("expected a foreach");
575        };
576
577        assert_eq!(collection, &Expr::parse("posts"));
578        assert!(key.is_none());
579        assert_eq!(value, "post");
580        assert_eq!(body.len(), 1);
581    }
582
583    #[test]
584    fn a_foreach_can_bind_a_key_too() {
585        let parsed = nodes("@foreach(meta as k => v){{ k }}@endforeach");
586        let Node::Foreach { key, value, .. } = &parsed[0] else {
587            panic!("expected a foreach");
588        };
589
590        assert_eq!(key.as_deref(), Some("k"));
591        assert_eq!(value, "v");
592    }
593
594    #[test]
595    fn blocks_nest() {
596        let parsed = nodes("@foreach(a as b)@if(b)x@endif@endforeach");
597        let Node::Foreach { body, .. } = &parsed[0] else { panic!("expected a foreach") };
598        assert!(matches!(body[0], Node::If { .. }));
599    }
600
601    #[test]
602    fn layout_directives_are_recognised() {
603        let template = parse("@extends('layout')@section('body')hi@endsection").unwrap();
604        assert_eq!(template.extends.as_deref(), Some("'layout'"));
605
606        let Node::Section { name, body } = &template.nodes[0] else {
607            panic!("expected a section, got {:?}", template.nodes);
608        };
609        assert_eq!(name, "body");
610        assert_eq!(body, &vec![Node::Text("hi".into())]);
611    }
612
613    #[test]
614    fn include_and_yield_unquote_their_names() {
615        assert_eq!(nodes("@include('parts.nav')"), vec![Node::Include("parts.nav".into())]);
616        assert_eq!(nodes("@yield(\"content\")"), vec![Node::Yield("content".into())]);
617    }
618
619    #[test]
620    fn an_unclosed_block_is_reported() {
621        let err = parse("@if(a)oops").err().expect("should fail");
622        assert!(err.message().contains("endif"), "{}", err.message());
623    }
624
625    #[test]
626    fn an_unknown_directive_is_reported() {
627        let err = parse("@nonsense(1)").err().expect("should fail");
628        assert!(err.message().contains("@nonsense"), "{}", err.message());
629    }
630
631    #[test]
632    fn a_directive_missing_its_argument_is_reported() {
633        assert!(parse("@if hello @endif").is_err());
634    }
635
636    #[test]
637    fn an_unterminated_interpolation_stays_text() {
638        assert_eq!(nodes("{{ oops"), vec![Node::Text("{{ oops".into())]);
639    }
640}