Skip to main content

rustlavel_view/
parser.rs

1//! Turning a token stream into a [`Template`].
2//!
3//! Every error a template author can make is caught here rather than at render
4//! time, and every one of them carries the file, line, and column: a view is
5//! edited by people who are not going to read a Rust backtrace.
6
7use crate::ast::{Branch, Node, Template, TemplateRef};
8use crate::expr::{self, Expr};
9use crate::lexer::{self, Token};
10use crate::source::{Span, line_column, syntax_error};
11use rustlavel_core::{Error, Result};
12use std::collections::BTreeMap;
13
14/// Lex and parse `source` as the view called `name`.
15pub fn parse(name: &str, source: &str) -> Result<Template> {
16    let mut parser = Parser {
17        tokens: lexer::tokenize(name, source)?,
18        pos: 0,
19        file: name,
20        source,
21        extends: None,
22        sections: BTreeMap::new(),
23    };
24
25    // With no stop set, `block` runs to the end of the file or fails trying.
26    let nodes = parser.block(&[])?;
27
28    Ok(Template {
29        name: name.to_string(),
30        nodes,
31        extends: parser.extends,
32        sections: parser.sections,
33    })
34}
35
36struct Parser<'a> {
37    tokens: Vec<Token>,
38    pos: usize,
39    file: &'a str,
40    source: &'a str,
41    extends: Option<TemplateRef>,
42    sections: BTreeMap<String, Vec<Node>>,
43}
44
45impl<'a> Parser<'a> {
46    /// Parse nodes until one of `stop` is reached; the stopping directive is
47    /// left for the caller to consume.
48    fn block(&mut self, stop: &[&str]) -> Result<Vec<Node>> {
49        let mut nodes = Vec::new();
50
51        while let Some(token) = self.tokens.get(self.pos).cloned() {
52            match &token {
53                Token::Text(text) => {
54                    self.pos += 1;
55                    nodes.push(Node::Text(text.clone()));
56                }
57                Token::Echo { source, offset, escaped } => {
58                    self.pos += 1;
59                    let expr = expr::parse(self.span(*offset, source.len()))?;
60                    nodes.push(Node::Echo { expr, escaped: *escaped });
61                }
62                Token::Directive { name, arguments, arguments_offset, offset } => {
63                    if stop.contains(&name.as_str()) {
64                        return Ok(nodes);
65                    }
66                    let span = self.span(*arguments_offset, arguments.len());
67                    let offset = *offset;
68                    self.pos += 1;
69                    match name.as_str() {
70                        "if" => nodes.push(self.conditional(span, offset)?),
71                        "foreach" => nodes.push(self.loop_over(span, offset)?),
72                        "section" => self.section(span, offset)?,
73                        "extends" => self.extends(span, offset)?,
74                        "yield" => nodes.push(self.yield_section(span)?),
75                        "lang" => nodes.push(self.lang(span)?),
76                        "route" => nodes.push(self.route(span)?),
77                        "include" => {
78                            let name = self.template_name(&span, "include")?;
79                            nodes.push(Node::Include(self.reference(name, offset)));
80                        }
81                        // Every remaining name closes a block that was never
82                        // opened — `@endif` on its own, and friends.
83                        _ => return Err(self.unexpected(&token)),
84                    }
85                }
86            }
87        }
88
89        Ok(nodes)
90    }
91
92    /// `@if(...) ... @elseif(...) ... @else ... @endif`.
93    fn conditional(&mut self, span: Span<'_>, offset: usize) -> Result<Node> {
94        const STOP: [&str; 3] = ["elseif", "else", "endif"];
95
96        let mut branches =
97            vec![Branch { condition: expr::parse(span)?, body: self.block(&STOP)? }];
98        let mut otherwise = None;
99
100        loop {
101            match self.tokens.get(self.pos).cloned() {
102                Some(Token::Directive { name, arguments, arguments_offset, .. })
103                    if name == "elseif" =>
104                {
105                    if otherwise.is_some() {
106                        return Err(self.here("`@elseif` cannot follow `@else`"));
107                    }
108                    let span = self.span(arguments_offset, arguments.len());
109                    self.pos += 1;
110                    branches.push(Branch {
111                        condition: expr::parse(span)?,
112                        body: self.block(&STOP)?,
113                    });
114                }
115                Some(Token::Directive { name, .. }) if name == "else" => {
116                    if otherwise.is_some() {
117                        return Err(self.here("`@else` appears twice in one `@if`"));
118                    }
119                    self.pos += 1;
120                    otherwise = Some(self.block(&STOP)?);
121                }
122                _ => break,
123            }
124        }
125
126        self.close("if", "endif", offset)?;
127        Ok(Node::If { branches, otherwise })
128    }
129
130    /// `@foreach(items as item) ... @endforeach`.
131    fn loop_over(&mut self, span: Span<'_>, offset: usize) -> Result<Node> {
132        let (subject, binding) = expr::parse_foreach(span)?;
133        let body = self.block(&["endforeach"])?;
134        self.close("foreach", "endforeach", offset)?;
135        Ok(Node::Foreach { subject, binding, body })
136    }
137
138    /// `@section("name") ... @endsection`, or the one-line
139    /// `@section("name", expr)` form.
140    fn section(&mut self, span: Span<'_>, offset: usize) -> Result<()> {
141        let arguments = expr::parse_arguments(span)?;
142        let name = self.literal(arguments.first(), &span, "section")?;
143
144        let body = match arguments.len() {
145            1 => {
146                let body = self.block(&["endsection"])?;
147                self.close("section", "endsection", offset)?;
148                body
149            }
150            2 => vec![Node::Echo { expr: arguments[1].clone(), escaped: true }],
151            _ => {
152                return Err(span.error(0, "`@section` takes a name and an optional value"));
153            }
154        };
155
156        // Two sections of one name in one file is always a mistake, and a
157        // silent "last one wins" is a miserable afternoon.
158        if self.sections.insert(name.clone(), body).is_some() {
159            return Err(syntax_error(
160                self.file,
161                self.source,
162                offset,
163                format!("`{name}` is defined by two `@section` blocks in this template"),
164            ));
165        }
166        Ok(())
167    }
168
169    /// `@extends("layouts.app")`.
170    fn extends(&mut self, span: Span<'_>, offset: usize) -> Result<()> {
171        let name = self.template_name(&span, "extends")?;
172        if self.extends.is_some() {
173            return Err(span.error(0, "a template can only `@extends` one layout"));
174        }
175        self.extends = Some(self.reference(name, offset));
176        Ok(())
177    }
178
179    /// `@yield("name")` or `@yield("name", "fallback")`.
180    fn yield_section(&mut self, span: Span<'_>) -> Result<Node> {
181        let arguments = expr::parse_arguments(span)?;
182        if arguments.len() > 2 {
183            return Err(span.error(0, "`@yield` takes a name and an optional fallback"));
184        }
185        Ok(Node::Yield {
186            section: self.literal(arguments.first(), &span, "yield")?,
187            default: arguments.get(1).cloned(),
188        })
189    }
190
191    /// `@lang("auth.sign_in")`, or with placeholders:
192    /// `@lang("auth.welcome", "name", user.name)`.
193    ///
194    /// Alternating name/value after the key rather than a map literal, because
195    /// this expression grammar has no map literal and inventing one for a
196    /// single directive is a worse trade than a convention. The names are
197    /// quoted like the key; the values are ordinary expressions, so a
198    /// placeholder can carry anything the page already has.
199    fn lang(&mut self, span: Span<'_>) -> Result<Node> {
200        let arguments = expr::parse_arguments(span)?;
201        let key = self.literal(arguments.first(), &span, "lang")?;
202
203        let rest = &arguments[1.min(arguments.len())..];
204        if rest.len() % 2 != 0 {
205            return Err(span.error(
206                0,
207                "`@lang` takes a key and then pairs of name and value, like \
208                 `@lang(\"mail.greeting\", \"name\", user.name)` — one name here has no value",
209            ));
210        }
211
212        let mut replacements = Vec::with_capacity(rest.len() / 2);
213        for pair in rest.chunks(2) {
214            let name = match &pair[0] {
215                Expr::Str(name) => name.clone(),
216                _ => {
217                    return Err(span.error(
218                        0,
219                        "a `@lang` placeholder name has to be quoted, like \
220                         `@lang(\"mail.greeting\", \"name\", user.name)`",
221                    ))
222                }
223            };
224            replacements.push((name, pair[1].clone()));
225        }
226
227        Ok(Node::Lang { key, replacements })
228    }
229
230    fn route(&mut self, span: Span<'_>) -> Result<Node> {
231        let arguments = expr::parse_arguments(span)?;
232        let name = self.literal(arguments.first(), &span, "route")?;
233
234        let rest = &arguments[1.min(arguments.len())..];
235        if rest.len() % 2 != 0 {
236            return Err(span.error(
237                0,
238                "`@route` takes a route name and then pairs of parameter and value, like \
239                 `@route(\"users.show\", \"id\", user.id)` — one parameter here has no value",
240            ));
241        }
242
243        let mut params = Vec::with_capacity(rest.len() / 2);
244        for pair in rest.chunks(2) {
245            let name = match &pair[0] {
246                Expr::Str(name) => name.clone(),
247                _ => {
248                    return Err(span.error(
249                        0,
250                        "a `@lang` placeholder name has to be quoted, like \
251                         `@lang(\"mail.greeting\", \"name\", user.name)`",
252                    ))
253                }
254            };
255            params.push((name, pair[1].clone()));
256        }
257
258        Ok(Node::Route { name, params })
259    }
260
261    /// Read the single quoted view name a directive expects.
262    fn template_name(&self, span: &Span<'_>, directive: &str) -> Result<String> {
263        let arguments = expr::parse_arguments(*span)?;
264        if arguments.len() != 1 {
265            return Err(span.error(0, format!("`@{directive}` takes exactly one view name")));
266        }
267        self.literal(arguments.first(), span, directive)
268    }
269
270    /// Insist on a quoted literal: a view name is resolved at parse time, so it
271    /// cannot come from the data.
272    fn literal(&self, expr: Option<&Expr>, span: &Span<'_>, directive: &str) -> Result<String> {
273        match expr {
274            Some(Expr::Str(text)) => Ok(text.clone()),
275            _ => Err(span.error(
276                0,
277                format!("`@{directive}` needs a quoted name, like `@{directive}(\"layouts.app\")`"),
278            )),
279        }
280    }
281
282    fn reference(&self, name: String, offset: usize) -> TemplateRef {
283        let (line, column) = line_column(self.source, offset);
284        TemplateRef { name, file: self.file.to_string(), line, column }
285    }
286
287    /// A fragment of the source, borrowed from it rather than from `self` so
288    /// the parser can keep mutating while the span is in hand.
289    fn span(&self, offset: usize, len: usize) -> Span<'a> {
290        Span::new(self.file, self.source, offset, len)
291    }
292
293    /// Consume the directive that closes a block, blaming the opener when it
294    /// is missing — the opener is what the author has to go fix.
295    fn close(&mut self, opener: &str, closer: &str, offset: usize) -> Result<()> {
296        match self.tokens.get(self.pos) {
297            Some(Token::Directive { name, .. }) if name == closer => {
298                self.pos += 1;
299                Ok(())
300            }
301            _ => Err(syntax_error(
302                self.file,
303                self.source,
304                offset,
305                format!("`@{opener}` is never closed — add `@{closer}`"),
306            )),
307        }
308    }
309
310    /// An error at the token the parser is looking at.
311    fn here(&self, message: impl Into<String>) -> Error {
312        let offset = match self.tokens.get(self.pos) {
313            Some(Token::Directive { offset, .. }) | Some(Token::Echo { offset, .. }) => *offset,
314            _ => self.source.len(),
315        };
316        syntax_error(self.file, self.source, offset, message)
317    }
318
319    fn unexpected(&self, token: &Token) -> Error {
320        match token {
321            Token::Directive { name, offset, .. } => {
322                let opener = match name.as_str() {
323                    "endif" | "elseif" | "else" => "@if",
324                    "endforeach" => "@foreach",
325                    "endsection" => "@section",
326                    _ => "a block",
327                };
328                syntax_error(
329                    self.file,
330                    self.source,
331                    *offset,
332                    format!("`@{name}` has no matching `{opener}`"),
333                )
334            }
335            _ => self.here("unexpected token"),
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn template(source: &str) -> Template {
345        parse("test", source).unwrap()
346    }
347
348    fn error(source: &str) -> (usize, usize, String) {
349        match parse("test", source).unwrap_err() {
350            Error::Template { line, column, message, .. } => (line, column, message),
351            other => panic!("expected a template error, got {other:?}"),
352        }
353    }
354
355    #[test]
356    fn builds_an_if_chain_with_every_arm() {
357        let nodes = template("@if(a)A@elseif(b)B@else C@endif").nodes;
358
359        match &nodes[0] {
360            Node::If { branches, otherwise } => {
361                assert_eq!(branches.len(), 2);
362                assert_eq!(branches[0].condition, Expr::Path("a".into()));
363                assert_eq!(branches[1].body, vec![Node::Text("B".into())]);
364                assert_eq!(otherwise.as_deref(), Some(&[Node::Text(" C".into())][..]));
365            }
366            other => panic!("expected an if, got {other:?}"),
367        }
368    }
369
370    #[test]
371    fn builds_a_foreach_with_its_binding() {
372        let nodes = template("@foreach(user.posts as post){{ post.title }}@endforeach").nodes;
373
374        match &nodes[0] {
375            Node::Foreach { subject, binding, body } => {
376                assert_eq!(subject, &Expr::Path("user.posts".into()));
377                assert_eq!(binding, "post");
378                assert_eq!(body.len(), 1);
379            }
380            other => panic!("expected a foreach, got {other:?}"),
381        }
382    }
383
384    #[test]
385    fn hoists_sections_out_of_the_node_stream() {
386        let parsed = template("@extends(\"layouts.app\")\n@section(\"body\")hi@endsection");
387
388        assert_eq!(parsed.extends.as_ref().map(|r| r.name.as_str()), Some("layouts.app"));
389        assert_eq!(parsed.sections["body"], vec![Node::Text("hi".into())]);
390        assert!(parsed.nodes.is_empty(), "{:?}", parsed.nodes);
391    }
392
393    #[test]
394    fn the_short_section_form_echoes_its_value() {
395        let parsed = template("@section(\"title\", page.name)");
396
397        assert_eq!(
398            parsed.sections["title"],
399            vec![Node::Echo { expr: Expr::Path("page.name".into()), escaped: true }]
400        );
401    }
402
403    #[test]
404    fn records_where_a_reference_was_written() {
405        let parsed = template("<p>\n  @include(\"parts.nav\")\n");
406
407        match &parsed.nodes[1] {
408            Node::Include(reference) => {
409                assert_eq!(reference.name, "parts.nav");
410                assert_eq!((reference.line, reference.column), (2, 3));
411            }
412            other => panic!("expected an include, got {other:?}"),
413        }
414    }
415
416    #[test]
417    fn an_unclosed_block_is_reported_at_the_line_it_opened() {
418        let (line, column, message) = error("<ul>\n@if(ready)\n  <li></li>\n</ul>\n");
419
420        assert_eq!((line, column), (2, 1));
421        assert!(message.contains("`@if` is never closed"), "{message}");
422    }
423
424    #[test]
425    fn a_stray_closing_directive_names_what_it_should_have_closed() {
426        let (line, _, message) = error("<p>ok</p>\n@endforeach\n");
427
428        assert_eq!(line, 2);
429        assert!(message.contains("@foreach"), "{message}");
430    }
431
432    #[test]
433    fn a_view_name_has_to_be_a_literal() {
434        let (_, _, message) = error("@include(page)");
435
436        assert!(message.contains("quoted name"), "{message}");
437    }
438
439    #[test]
440    fn two_sections_of_one_name_are_rejected() {
441        let (_, _, message) =
442            error("@section(\"a\")1@endsection\n@section(\"a\")2@endsection\n");
443
444        assert!(message.contains("two `@section`"), "{message}");
445    }
446
447    /// The key is resolved at parse time and cached with the template, so it
448    /// cannot come from the data — the same rule `@include` follows.
449    #[test]
450    fn a_lang_key_has_to_be_a_literal() {
451        let (_, _, message) = error(r#"@lang(key)"#);
452        assert!(message.contains("`@lang` needs a quoted name"), "{message}");
453    }
454
455    /// A placeholder without a value is a typo that would otherwise render the
456    /// name as if it were the phrase.
457    #[test]
458    fn a_lang_placeholder_needs_a_value() {
459        let (_, _, message) = error(r#"@lang("mail.greeting", "name")"#);
460        assert!(message.contains("no value"), "{message}");
461    }
462
463    #[test]
464    fn a_lang_placeholder_name_has_to_be_quoted() {
465        let (_, _, message) = error(r#"@lang("mail.greeting", name, user.name)"#);
466        assert!(message.contains("has to be quoted"), "{message}");
467    }
468
469    #[test]
470    fn a_lang_directive_keeps_its_key_and_pairs() {
471        let template = template(r#"@lang("auth.welcome", "name", user.name)"#);
472        match &template.nodes[0] {
473            Node::Lang { key, replacements } => {
474                assert_eq!(key, "auth.welcome");
475                assert_eq!(replacements.len(), 1);
476                assert_eq!(replacements[0].0, "name");
477            }
478            other => panic!("expected a Lang node, got {other:?}"),
479        }
480    }
481}