1use rainier_support::{Error, Result};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct Template {
15 pub extends: Option<String>,
17 pub nodes: Vec<Node>,
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum Node {
24 Text(String),
26 Echo {
28 expr: Expr,
30 raw: bool,
32 },
33 If {
35 branches: Vec<(Condition, Vec<Node>)>,
37 otherwise: Option<Vec<Node>>,
39 },
40 Foreach {
42 collection: Expr,
44 key: Option<String>,
46 value: String,
48 body: Vec<Node>,
50 },
51 Include(String),
53 Yield(String),
55 Section {
57 name: String,
59 body: Vec<Node>,
61 },
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Expr {
67 pub path: Vec<String>,
69}
70
71impl Expr {
72 pub fn parse(source: &str) -> Self {
74 Self { path: source.trim().split('.').map(str::to_string).collect() }
75 }
76
77 pub fn as_string(&self) -> String {
79 self.path.join(".")
80 }
81}
82
83#[derive(Debug, Clone, PartialEq)]
85pub enum Condition {
86 Truthy(Expr),
88 Falsy(Expr),
90 Compare {
92 left: Expr,
94 op: CompareOp,
96 right: Literal,
98 },
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum CompareOp {
104 Eq,
106 Ne,
108 Gt,
110 Gte,
112 Lt,
114 Lte,
116}
117
118#[derive(Debug, Clone, PartialEq)]
120pub enum Literal {
121 Text(String),
123 Number(f64),
125 Bool(bool),
127 Null,
129}
130
131#[derive(Debug, Clone, PartialEq)]
133enum Token {
134 Text(String),
135 Echo { expr: String, raw: bool },
136 Directive { name: String, arg: Option<String> },
137}
138
139pub fn parse(source: &str) -> Result<Template> {
141 let tokens = lex(source);
142 let mut cursor = 0;
143 let mut extends = None;
144
145 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
160fn 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 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 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
213fn 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 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 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
268fn 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 "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; break;
344 }
345 _ => {
346 *cursor += 1; 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
395fn 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 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 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 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}