1use 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
14pub 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 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 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 "include" => {
76 let name = self.template_name(&span, "include")?;
77 nodes.push(Node::Include(self.reference(name, offset)));
78 }
79 _ => return Err(self.unexpected(&token)),
82 }
83 }
84 }
85 }
86
87 Ok(nodes)
88 }
89
90 fn conditional(&mut self, span: Span<'_>, offset: usize) -> Result<Node> {
92 const STOP: [&str; 3] = ["elseif", "else", "endif"];
93
94 let mut branches =
95 vec![Branch { condition: expr::parse(span)?, body: self.block(&STOP)? }];
96 let mut otherwise = None;
97
98 loop {
99 match self.tokens.get(self.pos).cloned() {
100 Some(Token::Directive { name, arguments, arguments_offset, .. })
101 if name == "elseif" =>
102 {
103 if otherwise.is_some() {
104 return Err(self.here("`@elseif` cannot follow `@else`"));
105 }
106 let span = self.span(arguments_offset, arguments.len());
107 self.pos += 1;
108 branches.push(Branch {
109 condition: expr::parse(span)?,
110 body: self.block(&STOP)?,
111 });
112 }
113 Some(Token::Directive { name, .. }) if name == "else" => {
114 if otherwise.is_some() {
115 return Err(self.here("`@else` appears twice in one `@if`"));
116 }
117 self.pos += 1;
118 otherwise = Some(self.block(&STOP)?);
119 }
120 _ => break,
121 }
122 }
123
124 self.close("if", "endif", offset)?;
125 Ok(Node::If { branches, otherwise })
126 }
127
128 fn loop_over(&mut self, span: Span<'_>, offset: usize) -> Result<Node> {
130 let (subject, binding) = expr::parse_foreach(span)?;
131 let body = self.block(&["endforeach"])?;
132 self.close("foreach", "endforeach", offset)?;
133 Ok(Node::Foreach { subject, binding, body })
134 }
135
136 fn section(&mut self, span: Span<'_>, offset: usize) -> Result<()> {
139 let arguments = expr::parse_arguments(span)?;
140 let name = self.literal(arguments.first(), &span, "section")?;
141
142 let body = match arguments.len() {
143 1 => {
144 let body = self.block(&["endsection"])?;
145 self.close("section", "endsection", offset)?;
146 body
147 }
148 2 => vec![Node::Echo { expr: arguments[1].clone(), escaped: true }],
149 _ => {
150 return Err(span.error(0, "`@section` takes a name and an optional value"));
151 }
152 };
153
154 if self.sections.insert(name.clone(), body).is_some() {
157 return Err(syntax_error(
158 self.file,
159 self.source,
160 offset,
161 format!("`{name}` is defined by two `@section` blocks in this template"),
162 ));
163 }
164 Ok(())
165 }
166
167 fn extends(&mut self, span: Span<'_>, offset: usize) -> Result<()> {
169 let name = self.template_name(&span, "extends")?;
170 if self.extends.is_some() {
171 return Err(span.error(0, "a template can only `@extends` one layout"));
172 }
173 self.extends = Some(self.reference(name, offset));
174 Ok(())
175 }
176
177 fn yield_section(&mut self, span: Span<'_>) -> Result<Node> {
179 let arguments = expr::parse_arguments(span)?;
180 if arguments.len() > 2 {
181 return Err(span.error(0, "`@yield` takes a name and an optional fallback"));
182 }
183 Ok(Node::Yield {
184 section: self.literal(arguments.first(), &span, "yield")?,
185 default: arguments.get(1).cloned(),
186 })
187 }
188
189 fn template_name(&self, span: &Span<'_>, directive: &str) -> Result<String> {
191 let arguments = expr::parse_arguments(*span)?;
192 if arguments.len() != 1 {
193 return Err(span.error(0, format!("`@{directive}` takes exactly one view name")));
194 }
195 self.literal(arguments.first(), span, directive)
196 }
197
198 fn literal(&self, expr: Option<&Expr>, span: &Span<'_>, directive: &str) -> Result<String> {
201 match expr {
202 Some(Expr::Str(text)) => Ok(text.clone()),
203 _ => Err(span.error(
204 0,
205 format!("`@{directive}` needs a quoted name, like `@{directive}(\"layouts.app\")`"),
206 )),
207 }
208 }
209
210 fn reference(&self, name: String, offset: usize) -> TemplateRef {
211 let (line, column) = line_column(self.source, offset);
212 TemplateRef { name, file: self.file.to_string(), line, column }
213 }
214
215 fn span(&self, offset: usize, len: usize) -> Span<'a> {
218 Span::new(self.file, self.source, offset, len)
219 }
220
221 fn close(&mut self, opener: &str, closer: &str, offset: usize) -> Result<()> {
224 match self.tokens.get(self.pos) {
225 Some(Token::Directive { name, .. }) if name == closer => {
226 self.pos += 1;
227 Ok(())
228 }
229 _ => Err(syntax_error(
230 self.file,
231 self.source,
232 offset,
233 format!("`@{opener}` is never closed — add `@{closer}`"),
234 )),
235 }
236 }
237
238 fn here(&self, message: impl Into<String>) -> Error {
240 let offset = match self.tokens.get(self.pos) {
241 Some(Token::Directive { offset, .. }) | Some(Token::Echo { offset, .. }) => *offset,
242 _ => self.source.len(),
243 };
244 syntax_error(self.file, self.source, offset, message)
245 }
246
247 fn unexpected(&self, token: &Token) -> Error {
248 match token {
249 Token::Directive { name, offset, .. } => {
250 let opener = match name.as_str() {
251 "endif" | "elseif" | "else" => "@if",
252 "endforeach" => "@foreach",
253 "endsection" => "@section",
254 _ => "a block",
255 };
256 syntax_error(
257 self.file,
258 self.source,
259 *offset,
260 format!("`@{name}` has no matching `{opener}`"),
261 )
262 }
263 _ => self.here("unexpected token"),
264 }
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn template(source: &str) -> Template {
273 parse("test", source).unwrap()
274 }
275
276 fn error(source: &str) -> (usize, usize, String) {
277 match parse("test", source).unwrap_err() {
278 Error::Template { line, column, message, .. } => (line, column, message),
279 other => panic!("expected a template error, got {other:?}"),
280 }
281 }
282
283 #[test]
284 fn builds_an_if_chain_with_every_arm() {
285 let nodes = template("@if(a)A@elseif(b)B@else C@endif").nodes;
286
287 match &nodes[0] {
288 Node::If { branches, otherwise } => {
289 assert_eq!(branches.len(), 2);
290 assert_eq!(branches[0].condition, Expr::Path("a".into()));
291 assert_eq!(branches[1].body, vec![Node::Text("B".into())]);
292 assert_eq!(otherwise.as_deref(), Some(&[Node::Text(" C".into())][..]));
293 }
294 other => panic!("expected an if, got {other:?}"),
295 }
296 }
297
298 #[test]
299 fn builds_a_foreach_with_its_binding() {
300 let nodes = template("@foreach(user.posts as post){{ post.title }}@endforeach").nodes;
301
302 match &nodes[0] {
303 Node::Foreach { subject, binding, body } => {
304 assert_eq!(subject, &Expr::Path("user.posts".into()));
305 assert_eq!(binding, "post");
306 assert_eq!(body.len(), 1);
307 }
308 other => panic!("expected a foreach, got {other:?}"),
309 }
310 }
311
312 #[test]
313 fn hoists_sections_out_of_the_node_stream() {
314 let parsed = template("@extends(\"layouts.app\")\n@section(\"body\")hi@endsection");
315
316 assert_eq!(parsed.extends.as_ref().map(|r| r.name.as_str()), Some("layouts.app"));
317 assert_eq!(parsed.sections["body"], vec![Node::Text("hi".into())]);
318 assert!(parsed.nodes.is_empty(), "{:?}", parsed.nodes);
319 }
320
321 #[test]
322 fn the_short_section_form_echoes_its_value() {
323 let parsed = template("@section(\"title\", page.name)");
324
325 assert_eq!(
326 parsed.sections["title"],
327 vec![Node::Echo { expr: Expr::Path("page.name".into()), escaped: true }]
328 );
329 }
330
331 #[test]
332 fn records_where_a_reference_was_written() {
333 let parsed = template("<p>\n @include(\"parts.nav\")\n");
334
335 match &parsed.nodes[1] {
336 Node::Include(reference) => {
337 assert_eq!(reference.name, "parts.nav");
338 assert_eq!((reference.line, reference.column), (2, 3));
339 }
340 other => panic!("expected an include, got {other:?}"),
341 }
342 }
343
344 #[test]
345 fn an_unclosed_block_is_reported_at_the_line_it_opened() {
346 let (line, column, message) = error("<ul>\n@if(ready)\n <li></li>\n</ul>\n");
347
348 assert_eq!((line, column), (2, 1));
349 assert!(message.contains("`@if` is never closed"), "{message}");
350 }
351
352 #[test]
353 fn a_stray_closing_directive_names_what_it_should_have_closed() {
354 let (line, _, message) = error("<p>ok</p>\n@endforeach\n");
355
356 assert_eq!(line, 2);
357 assert!(message.contains("@foreach"), "{message}");
358 }
359
360 #[test]
361 fn a_view_name_has_to_be_a_literal() {
362 let (_, _, message) = error("@include(page)");
363
364 assert!(message.contains("quoted name"), "{message}");
365 }
366
367 #[test]
368 fn two_sections_of_one_name_are_rejected() {
369 let (_, _, message) =
370 error("@section(\"a\")1@endsection\n@section(\"a\")2@endsection\n");
371
372 assert!(message.contains("two `@section`"), "{message}");
373 }
374}