1use crate::ast::{GlobalEntry, Node};
2use crate::error::ParseError;
3
4const KEYWORDS: &[&str] = &[
5 "extends",
6 "section",
7 "endsection",
8 "yield",
9 "if",
10 "elseif",
11 "else",
12 "endif",
13 "foreach",
14 "endforeach",
15 "push",
16 "endpush",
17 "stack",
18 "csrf",
19 "global",
20 "globals",
21 "endglobals",
22 "wire",
23 "larustscripts",
24 "loadonce",
25 "endloadonce",
26 "resource",
27 "endresource",
28 "live",
29 "endlive",
30 "code",
31 "endcode",
32 "vitex",
33 "js",
34 "can",
35 "endcan",
36 "role",
37 "endrole",
38 "spa",
39 "endspa",
40];
41
42const CLOSERS: &[&str] = &[
48 "else",
49 "elseif",
50 "endif",
51 "endsection",
52 "endforeach",
53 "endpush",
54 "endglobals",
55 "endloadonce",
56 "endresource",
57 "endlive",
58 "endcode",
59 "endcan",
60 "endrole",
61 "endspa",
62];
63
64struct Closer {
79 tag: &'static str,
80 elseif_cond: Option<String>,
81 resource_tag_name: Option<String>,
82}
83
84pub fn parse(source: &str) -> Result<Vec<Node>, ParseError> {
85 let mut cursor = Cursor::new(source);
86 let (nodes, closer) = parse_nodes(&mut cursor)?;
87 if let Some(closer) = closer {
88 let message = match &closer.resource_tag_name {
89 Some(name) => {
90 format!("unexpected </resource:{name}> with no matching <resource:{name}>")
91 }
92 None => format!(
93 "unexpected @{} with no matching opening directive",
94 closer.tag
95 ),
96 };
97 return Err(ParseError::new(message));
98 }
99 Ok(nodes)
100}
101
102struct Cursor<'a> {
103 src: &'a str,
104 pos: usize,
105 in_script: bool,
114}
115
116impl<'a> Cursor<'a> {
117 fn new(src: &'a str) -> Self {
118 Self {
119 src,
120 pos: 0,
121 in_script: false,
122 }
123 }
124
125 fn rest(&self) -> &'a str {
126 &self.src[self.pos..]
127 }
128
129 fn advance(&mut self, n: usize) {
130 self.pos += n;
131 }
132
133 fn update_script_state(&mut self, consumed_text: &str) {
139 self.in_script = script_state_after(consumed_text, self.in_script);
140 }
141}
142
143fn script_state_after(text: &str, baseline: bool) -> bool {
166 let last_open = find_last_tag_boundary(text, "<script");
167 let last_close = find_last_tag_boundary(text, "</script");
168 match (last_open, last_close) {
169 (Some(open), Some(close)) => open > close,
170 (Some(_), None) => true,
171 (None, Some(_)) => false,
172 (None, None) => baseline,
173 }
174}
175
176fn find_last_tag_boundary(s: &str, tag: &str) -> Option<usize> {
182 let mut search_end = s.len();
183 loop {
184 let candidate = s.get(..search_end)?.rfind(tag)?;
185 let after = &s[candidate + tag.len()..];
186 let is_boundary = after
187 .chars()
188 .next()
189 .is_none_or(|c| !c.is_alphanumeric() && c != '_');
190 if is_boundary {
191 return Some(candidate);
192 }
193 search_end = candidate;
194 }
195}
196
197enum MarkerKind {
198 Escaped,
199 Raw,
200 Comment,
214 At(&'static str),
215 ResourceTagOpen,
216 ResourceTagClose,
217 WireTagOpen,
218 ImageTagOpen,
224}
225
226fn next_marker(s: &str, in_script: bool) -> Option<(usize, MarkerKind)> {
227 let at = find_next_at_directive(s, in_script).map(|(p, kw)| (p, MarkerKind::At(kw)));
228 let comment = s.find("{{--").map(|p| (p, MarkerKind::Comment));
234 let raw = s.find("{!!").map(|p| (p, MarkerKind::Raw));
235 let esc = s.find("{{").map(|p| (p, MarkerKind::Escaped));
236 let resource_close = s
241 .find("</resource:")
242 .map(|p| (p, MarkerKind::ResourceTagClose));
243 let resource_open = s
244 .find("<resource:")
245 .map(|p| (p, MarkerKind::ResourceTagOpen));
246 let wire_open = s.find("<wire:").map(|p| (p, MarkerKind::WireTagOpen));
247 let image_open = find_image_tag_open(s).map(|p| (p, MarkerKind::ImageTagOpen));
248
249 [
250 at,
251 comment,
252 raw,
253 esc,
254 resource_close,
255 resource_open,
256 wire_open,
257 image_open,
258 ]
259 .into_iter()
260 .flatten()
261 .min_by_key(|(p, _)| *p)
262}
263
264fn find_image_tag_open(s: &str) -> Option<usize> {
275 let mut search_from = 0;
276 while let Some(rel) = s[search_from..].find("<Image") {
277 let pos = search_from + rel;
278 let after = &s[pos + "<Image".len()..];
279 let boundary_ok = after
280 .chars()
281 .next()
282 .is_none_or(|c| c.is_whitespace() || c == '/' || c == '>');
283 if boundary_ok {
284 return Some(pos);
285 }
286 search_from = pos + 1;
287 }
288 None
289}
290
291fn find_next_at_directive(s: &str, baseline_in_script: bool) -> Option<(usize, &'static str)> {
309 let mut search_from = 0;
310 while let Some(rel) = s[search_from..].find('@') {
311 let pos = search_from + rel;
312 let in_script = script_state_after(&s[..pos], baseline_in_script);
313 if is_inside_a_comment(s, pos, in_script) {
314 search_from = pos + 1;
315 continue;
316 }
317 let after = &s[pos + 1..];
318 let hit = KEYWORDS.iter().find(|kw| {
319 after.starts_with(**kw)
320 && after[kw.len()..]
321 .chars()
322 .next()
323 .is_none_or(|c| !c.is_alphanumeric() && c != '_')
324 });
325 if let Some(kw) = hit {
326 return Some((pos, kw));
327 }
328 search_from = pos + 1;
329 }
330 None
331}
332
333fn is_inside_a_comment(s: &str, pos: usize, in_script: bool) -> bool {
350 #[derive(Clone, Copy, PartialEq)]
351 enum State {
352 Normal,
353 HtmlComment,
354 LineComment,
355 BlockComment,
356 JsString(char),
357 }
358
359 let mut rest = &s[..pos];
360 let mut state = State::Normal;
361
362 while !rest.is_empty() {
363 match state {
364 State::Normal => {
365 let html_comment = rest.find("<!--").map(|p| (p, State::HtmlComment, 4));
366 let line_comment = in_script
367 .then(|| rest.find("//"))
368 .flatten()
369 .map(|p| (p, State::LineComment, 2));
370 let block_comment = in_script
371 .then(|| rest.find("/*"))
372 .flatten()
373 .map(|p| (p, State::BlockComment, 2));
374 let string_start = in_script
375 .then(|| rest.find(['\'', '"', '`']))
376 .flatten()
377 .map(|p| {
378 let quote = rest[p..].chars().next().expect("just found at this offset");
379 (p, State::JsString(quote), 1)
380 });
381
382 let Some((found_at, new_state, skip)) =
383 [html_comment, line_comment, block_comment, string_start]
384 .into_iter()
385 .flatten()
386 .min_by_key(|(p, _, _)| *p)
387 else {
388 break; };
390 rest = &rest[found_at + skip..];
391 state = new_state;
392 }
393 State::HtmlComment => match rest.find("-->") {
394 Some(p) => {
395 rest = &rest[p + 3..];
396 state = State::Normal;
397 }
398 None => return true, },
400 State::LineComment => match rest.find('\n') {
401 Some(p) => {
402 rest = &rest[p + 1..];
403 state = State::Normal;
404 }
405 None => return true,
406 },
407 State::BlockComment => match rest.find("*/") {
408 Some(p) => {
409 rest = &rest[p + 2..];
410 state = State::Normal;
411 }
412 None => return true,
413 },
414 State::JsString(quote) => {
415 let mut search = rest;
416 loop {
417 match search.find(quote) {
418 Some(p) if p > 0 && search.as_bytes()[p - 1] == b'\\' => {
423 search = &search[p + 1..];
424 }
425 Some(p) => {
426 rest = &search[p + 1..];
427 state = State::Normal;
428 break;
429 }
430 None => return true,
431 }
432 }
433 }
434 }
435 }
436
437 matches!(
438 state,
439 State::HtmlComment | State::LineComment | State::BlockComment
440 )
441}
442
443fn read_closer(cur: &mut Cursor, kw: &'static str) -> Result<Closer, ParseError> {
448 let elseif_cond = if kw == "elseif" {
449 Some(parse_paren_expr(cur)?)
450 } else {
451 None
452 };
453 Ok(Closer {
454 tag: kw,
455 elseif_cond,
456 resource_tag_name: None,
457 })
458}
459
460fn parse_nodes(cur: &mut Cursor) -> Result<(Vec<Node>, Option<Closer>), ParseError> {
461 let mut nodes = Vec::new();
462
463 loop {
464 match next_marker(cur.rest(), cur.in_script) {
465 None => {
466 let text = cur.rest();
467 if !text.is_empty() {
468 cur.update_script_state(text);
469 nodes.push(Node::Text(text.to_string()));
470 }
471 cur.advance(text.len());
472 return Ok((nodes, None));
473 }
474 Some((offset, kind)) => {
475 if offset > 0 {
476 let text = &cur.rest()[..offset];
477 cur.update_script_state(text);
478 nodes.push(Node::Text(text.to_string()));
479 }
480 cur.advance(offset);
481
482 match kind {
483 MarkerKind::Comment => {
497 parse_braces(cur, "{{--", "--}}")?;
498 }
499 MarkerKind::Escaped => {
500 let expr = parse_braces(cur, "{{", "}}")?;
501 nodes.push(Node::Interpolate { expr, escape: true });
502 }
503 MarkerKind::Raw => {
504 let expr = parse_braces(cur, "{!!", "!!}")?;
505 nodes.push(Node::Interpolate {
506 expr,
507 escape: false,
508 });
509 }
510 MarkerKind::ResourceTagOpen => {
511 nodes.push(parse_resource_tag(cur)?);
512 }
513 MarkerKind::WireTagOpen => {
514 nodes.push(parse_wire_tag(cur)?);
515 }
516 MarkerKind::ImageTagOpen => {
517 nodes.push(parse_image_tag(cur)?);
518 }
519 MarkerKind::ResourceTagClose => {
520 cur.advance("</resource:".len());
521 let name = scan_tag_name(cur, "resource")?;
522 skip_ws(cur);
523 expect_char(cur, '>')?;
524 return Ok((
525 nodes,
526 Some(Closer {
527 tag: "endresourcetag",
528 elseif_cond: None,
529 resource_tag_name: Some(name),
530 }),
531 ));
532 }
533 MarkerKind::At(kw) => {
534 cur.advance(1 + kw.len()); if CLOSERS.contains(&kw) {
537 return Ok((nodes, Some(read_closer(cur, kw)?)));
538 }
539
540 match kw {
541 "extends" => {
542 let name = parse_quoted_arg(cur)?;
543 nodes.push(Node::Extends(name));
544 }
545 "yield" => {
546 let name = parse_quoted_arg(cur)?;
547 nodes.push(Node::Yield(name));
548 }
549 "section" => {
550 let name = parse_quoted_arg(cur)?;
551 let (body, closer) = parse_nodes(cur)?;
552 expect_closer(closer, "endsection")?;
553 nodes.push(Node::Section { name, body });
554 }
555 "if" => {
556 let cond = parse_paren_expr(cur)?;
557 let (then_branch, else_branch) = parse_if_tail(cur)?;
558 nodes.push(Node::If {
559 cond,
560 then_branch,
561 else_branch,
562 });
563 }
564 "foreach" => {
565 let raw = parse_paren_expr(cur)?;
566 let (binding, iter) = split_foreach(&raw)?;
567 let (body, closer) = parse_nodes(cur)?;
568 expect_closer(closer, "endforeach")?;
569 nodes.push(Node::Foreach {
570 binding,
571 iter,
572 body,
573 });
574 }
575 "push" => {
576 let name = parse_quoted_arg(cur)?;
577 let (body, closer) = parse_nodes(cur)?;
578 expect_closer(closer, "endpush")?;
579 nodes.push(Node::Push { name, body });
580 }
581 "stack" => {
582 let name = parse_quoted_arg(cur)?;
583 nodes.push(Node::Stack(name));
584 }
585 "csrf" => {
586 nodes.push(Node::Csrf);
587 }
588 "global" => {
589 let (name, fallback) = parse_global_args(cur)?;
590 nodes.push(Node::Global { name, fallback });
591 }
592 "globals" => {
593 let entries = parse_globals_block(cur)?;
594 nodes.push(Node::Globals(entries));
595 }
596 "wire" => {
597 let (name, props) = parse_wire_args(cur)?;
598 nodes.push(Node::Wire { name, props });
599 }
600 "larustscripts" => {
601 nodes.push(Node::LarustScripts);
602 }
603 "loadonce" => {
604 let (body, closer) = parse_nodes(cur)?;
605 expect_closer(closer, "endloadonce")?;
606 nodes.push(Node::LoadOnce(body));
607 }
608 "resource" => {
609 let (name, props) = parse_resource_args(cur)?;
610 let (slot, closer) = parse_nodes(cur)?;
611 expect_closer(closer, "endresource")?;
612 nodes.push(Node::Resource { name, props, slot });
613 }
614 "live" => {
615 let channel = parse_paren_expr(cur)?;
616 let (body, closer) = parse_nodes(cur)?;
617 expect_closer(closer, "endlive")?;
618 nodes.push(Node::Live { channel, body });
619 }
620 "code" => nodes.push(Node::Code(parse_code_block(cur)?)),
621 "js" => {
622 let expr = parse_paren_expr(cur)?;
623 nodes.push(Node::Js(expr));
624 }
625 "vitex" => {
626 let entries = parse_vitex_args(cur)?;
627 nodes.push(Node::Vitex(entries));
628 }
629 "can" => {
630 let permission = parse_paren_expr(cur)?;
631 let (then_branch, else_branch) = parse_else_tail(cur, "endcan")?;
632 nodes.push(Node::Can {
633 permission,
634 then_branch,
635 else_branch,
636 });
637 }
638 "role" => {
639 let role = parse_paren_expr(cur)?;
640 let (then_branch, else_branch) = parse_else_tail(cur, "endrole")?;
641 nodes.push(Node::Role {
642 role,
643 then_branch,
644 else_branch,
645 });
646 }
647 "spa" => {
648 let (body, closer) = parse_nodes(cur)?;
649 expect_closer(closer, "endspa")?;
650 nodes.push(Node::Spa(body));
651 }
652 other => {
653 return Err(ParseError::new(format!("unknown directive @{other}")))
654 }
655 }
656 }
657 }
658 }
659 }
660 }
661}
662
663fn parse_code_block(cur: &mut Cursor) -> Result<String, ParseError> {
667 let rest = cur.rest();
668 let end = rest
669 .find("@endcode")
670 .ok_or_else(|| ParseError::new("unterminated @code block, expected @endcode"))?;
671 let code = rest[..end].to_string();
672 cur.advance(end + "@endcode".len());
673 Ok(code)
674}
675
676fn parse_if_tail(cur: &mut Cursor) -> Result<(Vec<Node>, Vec<Node>), ParseError> {
684 let (then_branch, closer) = parse_nodes(cur)?;
685 match closer {
686 Some(Closer { tag: "endif", .. }) => Ok((then_branch, Vec::new())),
687 Some(Closer { tag: "else", .. }) => {
688 let (else_branch, closer2) = parse_nodes(cur)?;
689 expect_closer(closer2, "endif")?;
690 Ok((then_branch, else_branch))
691 }
692 Some(Closer {
693 tag: "elseif",
694 elseif_cond: Some(cond),
695 ..
696 }) => {
697 let (nested_then, nested_else) = parse_if_tail(cur)?;
698 let else_branch = vec![Node::If {
699 cond,
700 then_branch: nested_then,
701 else_branch: nested_else,
702 }];
703 Ok((then_branch, else_branch))
704 }
705 other => Err(unexpected_closer(other, "@elseif, @else, or @endif")),
706 }
707}
708
709fn parse_else_tail(
718 cur: &mut Cursor,
719 end_tag: &'static str,
720) -> Result<(Vec<Node>, Vec<Node>), ParseError> {
721 let (then_branch, closer) = parse_nodes(cur)?;
722 match closer {
723 Some(Closer { tag, .. }) if tag == end_tag => Ok((then_branch, Vec::new())),
724 Some(Closer { tag: "else", .. }) => {
725 let (else_branch, closer2) = parse_nodes(cur)?;
726 expect_closer(closer2, end_tag)?;
727 Ok((then_branch, else_branch))
728 }
729 other => Err(unexpected_closer(other, &format!("@else or @{end_tag}"))),
730 }
731}
732
733fn parse_braces(cur: &mut Cursor, open: &str, close: &str) -> Result<String, ParseError> {
734 cur.advance(open.len());
735 let s = cur.rest();
736 let end = s
737 .find(close)
738 .ok_or_else(|| ParseError::new(format!("unterminated {open} ... {close}")))?;
739 let expr = s[..end].trim().to_string();
740 cur.advance(end + close.len());
741 Ok(expr)
742}
743
744fn parse_quoted_arg(cur: &mut Cursor) -> Result<String, ParseError> {
745 skip_ws(cur);
746 expect_char(cur, '(')?;
747 skip_ws(cur);
748 let value = parse_quoted_string(cur)?;
749 skip_ws(cur);
750 expect_char(cur, ')')?;
751 Ok(value)
752}
753
754fn parse_quoted_string(cur: &mut Cursor) -> Result<String, ParseError> {
761 let quote = expect_one_of(cur, &['\'', '"'])?;
762
763 let s = cur.rest();
764 let end = s
765 .find(quote)
766 .ok_or_else(|| ParseError::new("unterminated string in directive argument"))?;
767 let value = s[..end].to_string();
768 cur.advance(end + 1); Ok(value)
770}
771
772fn parse_vitex_args(cur: &mut Cursor) -> Result<Vec<String>, ParseError> {
781 skip_ws(cur);
782 expect_char(cur, '(')?;
783 skip_ws(cur);
784 expect_char(cur, '[')?;
785
786 let mut entries = Vec::new();
787 loop {
788 skip_ws(cur);
789 if cur.rest().starts_with(']') {
790 cur.advance(1);
791 break;
792 }
793 entries.push(parse_quoted_string(cur)?);
794 skip_ws(cur);
795 match cur.rest().chars().next() {
796 Some(',') => cur.advance(1),
797 Some(']') => {
798 cur.advance(1);
799 break;
800 }
801 _ => return Err(ParseError::new("expected ',' or ']' in @vitex([...])")),
802 }
803 }
804 skip_ws(cur);
805 expect_char(cur, ')')?;
806 if entries.is_empty() {
807 return Err(ParseError::new(
808 "@vitex([...]) needs at least one entry path",
809 ));
810 }
811 Ok(entries)
812}
813
814fn parse_global_args(cur: &mut Cursor) -> Result<(String, Option<String>), ParseError> {
824 skip_ws(cur);
825 expect_char(cur, '(')?;
826 skip_ws(cur);
827
828 let s = cur.rest();
829 let end = s
830 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
831 .unwrap_or(s.len());
832 if end == 0 {
833 return Err(ParseError::new("expected a variable name in @global(...)"));
834 }
835 let name = s[..end].to_string();
836 cur.advance(end);
837 skip_ws(cur);
838
839 match cur.rest().chars().next() {
840 Some(')') => {
841 cur.advance(1);
842 Ok((name, None))
843 }
844 Some(',') => {
845 cur.advance(1);
846 skip_ws(cur);
847 let fallback = scan_to_matching_close_paren(cur)?;
848 if fallback.is_empty() {
849 return Err(ParseError::new(
850 "expected a fallback expression after ',' in @global(...)",
851 ));
852 }
853 Ok((name, Some(fallback)))
854 }
855 Some(c) => Err(ParseError::new(format!(
856 "expected ',' or ')' in @global(...), found '{c}'"
857 ))),
858 None => Err(ParseError::new(
859 "expected ',' or ')' in @global(...), found end of template",
860 )),
861 }
862}
863
864fn parse_globals_block(cur: &mut Cursor) -> Result<Vec<GlobalEntry>, ParseError> {
871 let s = cur.rest();
872 let end = s
873 .find("@endglobals")
874 .ok_or_else(|| ParseError::new("unterminated @globals block, expected @endglobals"))?;
875 let block_text = &s[..end];
876 let entries = parse_globals_entries(block_text)?;
877 cur.advance(end + "@endglobals".len());
878 Ok(entries)
879}
880
881const PERSIST_PREFIX: &str = "persist ";
887
888fn parse_globals_entries(block_text: &str) -> Result<Vec<GlobalEntry>, ParseError> {
889 let mut entries = Vec::new();
890 for line in block_text.lines() {
891 let line = line.trim();
892 if line.is_empty() {
893 continue;
894 }
895 let (persist, line) = match line.strip_prefix(PERSIST_PREFIX) {
896 Some(rest) => (true, rest.trim_start()),
897 None => (false, line),
898 };
899 let split = find_assignment_split(line).ok_or_else(|| {
900 ParseError::new(format!(
901 "expected `name = expression` in @globals block, found: `{line}`"
902 ))
903 })?;
904 let name = line[..split].trim().to_string();
905 let expr = line[split + 1..].trim().to_string();
906 if !is_valid_global_name(&name) {
914 return Err(ParseError::new(format!(
915 "invalid variable name `{name}` in @globals block - only letters, digits, and \
916 underscores are allowed, matching @global(name)'s own placeholder syntax"
917 )));
918 }
919 if expr.is_empty() {
920 return Err(ParseError::new(format!(
921 "expected an expression after '=' in @globals block, found: `{line}`"
922 )));
923 }
924 entries.push(GlobalEntry {
925 name,
926 expr,
927 persist,
928 });
929 }
930 Ok(entries)
931}
932
933fn parse_wire_args(cur: &mut Cursor) -> Result<(String, Vec<(String, String)>), ParseError> {
938 parse_name_and_props_args(cur, "wire")
939}
940
941fn parse_resource_args(cur: &mut Cursor) -> Result<(String, Vec<(String, String)>), ParseError> {
948 parse_name_and_props_args(cur, "resource")
949}
950
951fn parse_name_and_props_args(
955 cur: &mut Cursor,
956 directive: &str,
957) -> Result<(String, Vec<(String, String)>), ParseError> {
958 skip_ws(cur);
959 expect_char(cur, '(')?;
960 skip_ws(cur);
961 let name = parse_quoted_string(cur)?;
962 skip_ws(cur);
963
964 match cur.rest().chars().next() {
965 Some(')') => {
966 cur.advance(1);
967 Ok((name, Vec::new()))
968 }
969 Some(',') => {
970 cur.advance(1);
971 skip_ws(cur);
972 expect_char(cur, '{')?;
973 let props = parse_prop_entries(cur)?;
974 skip_ws(cur);
975 expect_char(cur, ')')?;
976 Ok((name, props))
977 }
978 Some(c) => Err(ParseError::new(format!(
979 "expected ',' or ')' in @{directive}(...), found '{c}'"
980 ))),
981 None => Err(ParseError::new(format!(
982 "expected ',' or ')' in @{directive}(...), found end of template"
983 ))),
984 }
985}
986
987fn parse_resource_tag(cur: &mut Cursor) -> Result<Node, ParseError> {
1004 cur.advance("<resource:".len());
1005 let name = scan_tag_name(cur, "resource")?;
1006 let props = parse_tag_attrs(cur, "resource")?;
1007 skip_ws(cur);
1008
1009 if cur.rest().starts_with("/>") {
1010 cur.advance(2);
1011 return Ok(Node::Resource {
1012 name,
1013 props,
1014 slot: Vec::new(),
1015 });
1016 }
1017
1018 expect_char(cur, '>')?;
1019 let (slot, closer) = parse_nodes(cur)?;
1020 match closer {
1021 Some(Closer {
1022 resource_tag_name: Some(found),
1023 ..
1024 }) if found == name => Ok(Node::Resource { name, props, slot }),
1025 other => Err(unexpected_closer(other, &format!("</resource:{name}>"))),
1033 }
1034}
1035
1036fn parse_wire_tag(cur: &mut Cursor) -> Result<Node, ParseError> {
1045 cur.advance("<wire:".len());
1046 let name = scan_tag_name(cur, "wire")?;
1047 let props = parse_tag_attrs(cur, "wire")?;
1048 skip_ws(cur);
1049
1050 if cur.rest().starts_with("/>") {
1051 cur.advance(2);
1052 return Ok(Node::Wire { name, props });
1053 }
1054
1055 match cur.rest().chars().next() {
1056 Some('>') => Err(ParseError::new(format!(
1057 "<wire:{name}> must be self-closing ('/>') - it has no closing tag or slot, \
1058 unlike <resource:...>"
1059 ))),
1060 Some(c) => Err(ParseError::new(format!(
1061 "expected '/>' to close <wire:{name}>, found '{c}'"
1062 ))),
1063 None => Err(ParseError::new(format!(
1064 "expected '/>' to close <wire:{name}>, found end of template"
1065 ))),
1066 }
1067}
1068
1069fn parse_image_tag(cur: &mut Cursor) -> Result<Node, ParseError> {
1085 cur.advance("<Image".len());
1086 let props = parse_tag_attrs(cur, "Image")?;
1087 skip_ws(cur);
1088
1089 if cur.rest().starts_with("/>") {
1090 cur.advance(2);
1091 return Ok(Node::Image { props });
1092 }
1093
1094 match cur.rest().chars().next() {
1095 Some('>') => Err(ParseError::new(
1096 "<Image ...> must be self-closing ('/>') - it has no closing tag or slot",
1097 )),
1098 Some(c) => Err(ParseError::new(format!(
1099 "expected '/>' to close <Image ...>, found '{c}'"
1100 ))),
1101 None => Err(ParseError::new(
1102 "expected '/>' to close <Image ...>, found end of template",
1103 )),
1104 }
1105}
1106
1107fn scan_tag_name(cur: &mut Cursor, tag_prefix: &str) -> Result<String, ParseError> {
1114 let s = cur.rest();
1115 let end = s
1116 .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
1117 .unwrap_or(s.len());
1118 if end == 0 {
1119 return Err(ParseError::new(format!(
1120 "expected a name after '<{tag_prefix}:'"
1121 )));
1122 }
1123 let name = s[..end].to_string();
1124 cur.advance(end);
1125 Ok(name)
1126}
1127
1128fn parse_tag_attrs(
1133 cur: &mut Cursor,
1134 tag_prefix: &str,
1135) -> Result<Vec<(String, String)>, ParseError> {
1136 let mut props = Vec::new();
1137 loop {
1138 skip_ws(cur);
1139 match cur.rest().chars().next() {
1140 Some('/') | Some('>') | None => return Ok(props),
1141 _ => {}
1142 }
1143
1144 let dynamic = cur.rest().starts_with(':');
1145 if dynamic {
1146 cur.advance(1);
1147 }
1148
1149 let s = cur.rest();
1150 let end = s
1151 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
1152 .unwrap_or(s.len());
1153 if end == 0 {
1154 return Err(ParseError::new(format!(
1155 "expected an attribute name in <{tag_prefix}:...> tag"
1156 )));
1157 }
1158 let attr_name = s[..end].to_string();
1159 cur.advance(end);
1160 skip_ws(cur);
1161 expect_char(cur, '=')?;
1162 skip_ws(cur);
1163 let raw_value = parse_quoted_string(cur)?;
1164
1165 let expr = if dynamic {
1166 raw_value
1167 } else {
1168 literal_attr_to_rust_string(&raw_value)
1169 };
1170 props.push((attr_name, expr));
1171 }
1172}
1173
1174fn literal_attr_to_rust_string(raw: &str) -> String {
1179 let mut escaped = String::with_capacity(raw.len() + 2);
1180 escaped.push('"');
1181 for c in raw.chars() {
1182 match c {
1183 '"' => escaped.push_str("\\\""),
1184 '\\' => escaped.push_str("\\\\"),
1185 _ => escaped.push(c),
1186 }
1187 }
1188 escaped.push('"');
1189 escaped
1190}
1191
1192fn parse_prop_entries(cur: &mut Cursor) -> Result<Vec<(String, String)>, ParseError> {
1205 let s = cur.rest();
1206 let mut depth: i32 = 1;
1207 let mut end = None;
1208 let mut chars = s.char_indices().peekable();
1209
1210 while let Some((i, c)) = chars.next() {
1211 match c {
1212 '(' | '{' | '[' => depth += 1,
1213 ')' | '}' | ']' => {
1214 depth -= 1;
1215 if depth == 0 {
1216 end = Some(i);
1217 break;
1218 }
1219 }
1220 '"' | '\'' => {
1221 let quote = c;
1222 while let Some((_, ch)) = chars.next() {
1223 if ch == '\\' {
1224 chars.next();
1225 continue;
1226 }
1227 if ch == quote {
1228 break;
1229 }
1230 }
1231 }
1232 _ => {}
1233 }
1234 }
1235
1236 let end = end.ok_or_else(|| ParseError::new("unterminated '{' in @wire(...) props"))?;
1237 let body = &s[..end];
1238 cur.advance(end + 1);
1239
1240 split_prop_entries(body)
1241}
1242
1243fn split_prop_entries(body: &str) -> Result<Vec<(String, String)>, ParseError> {
1250 let mut entries = Vec::new();
1251 let mut depth: i32 = 0;
1252 let mut start = 0;
1253 let bytes = body.as_bytes();
1254 let mut i = 0;
1255
1256 while i < bytes.len() {
1257 match bytes[i] {
1258 b'(' | b'{' | b'[' => {
1259 depth += 1;
1260 i += 1;
1261 }
1262 b')' | b'}' | b']' => {
1263 depth -= 1;
1264 i += 1;
1265 }
1266 b'"' | b'\'' => {
1267 let quote = bytes[i];
1268 i += 1;
1269 while i < bytes.len() {
1270 if bytes[i] == b'\\' {
1271 i += 2;
1272 continue;
1273 }
1274 if bytes[i] == quote {
1275 i += 1;
1276 break;
1277 }
1278 i += 1;
1279 }
1280 }
1281 b',' if depth == 0 => {
1282 push_prop_entry(&mut entries, body[start..i].trim())?;
1283 start = i + 1;
1284 i += 1;
1285 }
1286 _ => i += 1,
1287 }
1288 }
1289 push_prop_entry(&mut entries, body[start..].trim())?;
1290
1291 Ok(entries)
1292}
1293
1294fn push_prop_entry(entries: &mut Vec<(String, String)>, entry: &str) -> Result<(), ParseError> {
1295 if entry.is_empty() {
1296 return Ok(());
1297 }
1298 let split = find_prop_colon(entry).ok_or_else(|| {
1299 ParseError::new(format!(
1300 "expected `name: expression` in @wire(...) props, found: `{entry}`"
1301 ))
1302 })?;
1303 let name = entry[..split].trim().to_string();
1304 let expr = entry[split + 1..].trim().to_string();
1305 if !is_valid_global_name(&name) {
1309 return Err(ParseError::new(format!(
1310 "invalid prop name `{name}` in @wire(...) - only letters, digits, and underscores \
1311 are allowed"
1312 )));
1313 }
1314 if expr.is_empty() {
1315 return Err(ParseError::new(format!(
1316 "expected an expression after ':' in @wire(...) props, found: `{entry}`"
1317 )));
1318 }
1319 entries.push((name, expr));
1320 Ok(())
1321}
1322
1323fn find_prop_colon(entry: &str) -> Option<usize> {
1332 let bytes = entry.as_bytes();
1333 let mut i = 0;
1334 while i < bytes.len() {
1335 match bytes[i] {
1336 b'"' | b'\'' => {
1337 let quote = bytes[i];
1338 i += 1;
1339 while i < bytes.len() {
1340 if bytes[i] == b'\\' {
1341 i += 2;
1342 continue;
1343 }
1344 if bytes[i] == quote {
1345 i += 1;
1346 break;
1347 }
1348 i += 1;
1349 }
1350 }
1351 b':' => return Some(i),
1352 _ => i += 1,
1353 }
1354 }
1355 None
1356}
1357
1358fn is_valid_global_name(name: &str) -> bool {
1363 !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
1364}
1365
1366fn find_assignment_split(line: &str) -> Option<usize> {
1373 let bytes = line.as_bytes();
1374 let mut i = 0;
1375 while i < bytes.len() {
1376 match bytes[i] {
1377 b'"' | b'\'' => {
1378 let quote = bytes[i];
1379 i += 1;
1380 while i < bytes.len() {
1381 if bytes[i] == b'\\' {
1382 i += 2;
1383 continue;
1384 }
1385 if bytes[i] == quote {
1386 i += 1;
1387 break;
1388 }
1389 i += 1;
1390 }
1391 }
1392 b'=' => {
1393 let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
1394 let next = bytes.get(i + 1).copied();
1395 let is_operator_char =
1396 |b: Option<u8>| matches!(b, Some(b'!') | Some(b'<') | Some(b'>') | Some(b'='));
1397 if !is_operator_char(prev) && next != Some(b'=') {
1398 return Some(i);
1399 }
1400 i += 1;
1401 }
1402 _ => i += 1,
1403 }
1404 }
1405 None
1406}
1407
1408fn parse_paren_expr(cur: &mut Cursor) -> Result<String, ParseError> {
1414 skip_ws(cur);
1415 expect_char(cur, '(')?;
1416 scan_to_matching_close_paren(cur)
1417}
1418
1419fn scan_to_matching_close_paren(cur: &mut Cursor) -> Result<String, ParseError> {
1426 let s = cur.rest();
1427 let mut depth: i32 = 1;
1428 let mut end = None;
1429 let mut chars = s.char_indices().peekable();
1430
1431 while let Some((i, c)) = chars.next() {
1432 match c {
1433 '(' => depth += 1,
1434 ')' => {
1435 depth -= 1;
1436 if depth == 0 {
1437 end = Some(i);
1438 break;
1439 }
1440 }
1441 '"' | '\'' => {
1442 let quote = c;
1443 while let Some((_, ch)) = chars.next() {
1444 if ch == '\\' {
1445 chars.next();
1446 continue;
1447 }
1448 if ch == quote {
1449 break;
1450 }
1451 }
1452 }
1453 _ => {}
1454 }
1455 }
1456
1457 let end = end.ok_or_else(|| ParseError::new("unterminated '(' in directive"))?;
1458 let expr = s[..end].trim().to_string();
1459 cur.advance(end + 1);
1460 Ok(expr)
1461}
1462
1463fn split_foreach(raw: &str) -> Result<(String, String), ParseError> {
1464 let idx = raw
1465 .find(" in ")
1466 .ok_or_else(|| ParseError::new("expected `binding in iterable` in @foreach(...)"))?;
1467 let binding = raw[..idx].trim().to_string();
1468 let iter = raw[idx + 4..].trim().to_string();
1469 if binding.is_empty() || iter.is_empty() {
1470 return Err(ParseError::new(
1471 "expected `binding in iterable` in @foreach(...)",
1472 ));
1473 }
1474 Ok((binding, iter))
1475}
1476
1477fn expect_closer(found: Option<Closer>, expected: &str) -> Result<(), ParseError> {
1478 match found {
1479 Some(Closer { tag, .. }) if tag == expected => Ok(()),
1480 other => Err(unexpected_closer(other, &format!("@{expected}"))),
1481 }
1482}
1483
1484fn unexpected_closer(found: Option<Closer>, expected: &str) -> ParseError {
1485 match found {
1486 Some(closer) => ParseError::new(format!(
1487 "expected {expected}, found {}",
1488 describe_closer(&closer)
1489 )),
1490 None => ParseError::new(format!("unexpected end of template, expected {expected}")),
1491 }
1492}
1493
1494fn describe_closer(closer: &Closer) -> String {
1498 match &closer.resource_tag_name {
1499 Some(name) => format!("</resource:{name}>"),
1500 None => format!("@{}", closer.tag),
1501 }
1502}
1503
1504fn skip_ws(cur: &mut Cursor) {
1505 let n = cur.rest().len() - cur.rest().trim_start().len();
1506 cur.advance(n);
1507}
1508
1509fn expect_char(cur: &mut Cursor, expected: char) -> Result<(), ParseError> {
1510 match cur.rest().chars().next() {
1511 Some(c) if c == expected => {
1512 cur.advance(c.len_utf8());
1513 Ok(())
1514 }
1515 Some(c) => Err(ParseError::new(format!(
1516 "expected '{expected}', found '{c}'"
1517 ))),
1518 None => Err(ParseError::new(format!(
1519 "expected '{expected}', found end of template"
1520 ))),
1521 }
1522}
1523
1524fn expect_one_of(cur: &mut Cursor, options: &[char]) -> Result<char, ParseError> {
1525 match cur.rest().chars().next() {
1526 Some(c) if options.contains(&c) => {
1527 cur.advance(c.len_utf8());
1528 Ok(c)
1529 }
1530 Some(c) => Err(ParseError::new(format!(
1531 "expected one of {options:?}, found '{c}'"
1532 ))),
1533 None => Err(ParseError::new(format!(
1534 "expected one of {options:?}, found end of template"
1535 ))),
1536 }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541 use super::*;
1542
1543 #[test]
1544 fn parses_plain_text() {
1545 let nodes = parse("hello world").unwrap();
1546 assert_eq!(nodes, vec![Node::Text("hello world".to_string())]);
1547 }
1548
1549 #[test]
1550 fn parses_escaped_interpolation() {
1551 let nodes = parse("Hi {{ user.name }}!").unwrap();
1552 assert_eq!(
1553 nodes,
1554 vec![
1555 Node::Text("Hi ".to_string()),
1556 Node::Interpolate {
1557 expr: "user.name".to_string(),
1558 escape: true
1559 },
1560 Node::Text("!".to_string()),
1561 ]
1562 );
1563 }
1564
1565 #[test]
1566 fn parses_raw_interpolation() {
1567 let nodes = parse("{!! html !!}").unwrap();
1568 assert_eq!(
1569 nodes,
1570 vec![Node::Interpolate {
1571 expr: "html".to_string(),
1572 escape: false
1573 }]
1574 );
1575 }
1576
1577 #[test]
1578 fn a_comment_produces_no_node_at_all() {
1579 let nodes = parse("before {{-- a comment --}} after").unwrap();
1580 assert_eq!(
1581 nodes,
1582 vec![
1583 Node::Text("before ".to_string()),
1584 Node::Text(" after".to_string()),
1585 ]
1586 );
1587 }
1588
1589 #[test]
1590 fn a_directive_name_mentioned_inside_a_js_line_comment_in_a_script_block_is_plain_text() {
1591 let source = "<script>\n// see @push('head') and @wire for how this works\nconsole.log(1);\n</script>";
1596 let nodes = parse(source).unwrap();
1597 assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1598 }
1599
1600 #[test]
1601 fn a_directive_name_inside_a_js_block_comment_in_a_script_block_is_plain_text() {
1602 let source = "<script>/* uses @extends internally, not really */\nfoo();</script>";
1603 let nodes = parse(source).unwrap();
1604 assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1605 }
1606
1607 #[test]
1608 fn a_directive_name_inside_an_html_comment_is_plain_text_even_outside_a_script_block() {
1609 let source = "<!-- this page used to use @extends but doesn't anymore --><p>hi</p>";
1610 let nodes = parse(source).unwrap();
1611 assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1612 }
1613
1614 #[test]
1615 fn a_url_containing_double_slashes_inside_a_script_block_does_not_swallow_a_real_directive_after_it(
1616 ) {
1617 let source = r#"<script>var url = "http://example.com"; </script>@csrf"#;
1623 let nodes = parse(source).unwrap();
1624 assert_eq!(
1625 nodes,
1626 vec![
1627 Node::Text(r#"<script>var url = "http://example.com"; </script>"#.to_string()),
1628 Node::Csrf,
1629 ]
1630 );
1631 }
1632
1633 #[test]
1634 fn a_real_directive_still_works_normally_inside_a_script_block() {
1635 let nodes = parse("<script>@csrf</script>").unwrap();
1640 assert_eq!(
1641 nodes,
1642 vec![
1643 Node::Text("<script>".to_string()),
1644 Node::Csrf,
1645 Node::Text("</script>".to_string()),
1646 ]
1647 );
1648 }
1649
1650 #[test]
1651 fn a_double_slash_outside_any_script_block_is_never_treated_as_a_comment() {
1652 let nodes = parse("Visit https://example.com for more, then @csrf").unwrap();
1657 assert_eq!(
1658 nodes,
1659 vec![
1660 Node::Text("Visit https://example.com for more, then ".to_string()),
1661 Node::Csrf,
1662 ]
1663 );
1664 }
1665
1666 #[test]
1667 fn a_comment_containing_its_own_interpolation_and_directive_syntax_is_fully_inert() {
1668 let nodes = parse("{{-- @if(ok) {{ post.title }} @endif --}}{{ real }}").unwrap();
1675 assert_eq!(
1676 nodes,
1677 vec![Node::Interpolate {
1678 expr: "real".to_string(),
1679 escape: true
1680 }]
1681 );
1682 }
1683
1684 #[test]
1685 fn a_comment_can_contain_a_literal_dashes_close_brace_sequence_only_at_its_own_end() {
1686 let nodes = parse("{{-- one --}}{{-- two --}}").unwrap();
1691 assert_eq!(nodes, Vec::<Node>::new());
1692 }
1693
1694 #[test]
1695 fn an_unterminated_comment_is_a_clear_error() {
1696 let err = parse("{{-- never closed").unwrap_err();
1697 assert!(
1698 err.to_string().contains("unterminated"),
1699 "message was: {err}"
1700 );
1701 }
1702
1703 #[test]
1704 fn a_comment_takes_precedence_over_a_plain_double_brace_at_the_same_position() {
1705 let nodes = parse("{{-- x --}}").unwrap();
1710 assert_eq!(nodes, Vec::<Node>::new());
1711 }
1712
1713 #[test]
1714 fn literal_at_sign_is_not_a_directive() {
1715 let nodes = parse("contact: hello@example.com").unwrap();
1716 assert_eq!(
1717 nodes,
1718 vec![Node::Text("contact: hello@example.com".to_string())]
1719 );
1720 }
1721
1722 #[test]
1723 fn parses_if_else() {
1724 let nodes = parse("@if(user.admin) admin @else guest @endif").unwrap();
1725 assert_eq!(
1726 nodes,
1727 vec![Node::If {
1728 cond: "user.admin".to_string(),
1729 then_branch: vec![Node::Text(" admin ".to_string())],
1730 else_branch: vec![Node::Text(" guest ".to_string())],
1731 }]
1732 );
1733 }
1734
1735 #[test]
1736 fn parses_if_without_else() {
1737 let nodes = parse("@if(ok) yes @endif").unwrap();
1738 assert_eq!(
1739 nodes,
1740 vec![Node::If {
1741 cond: "ok".to_string(),
1742 then_branch: vec![Node::Text(" yes ".to_string())],
1743 else_branch: vec![],
1744 }]
1745 );
1746 }
1747
1748 #[test]
1749 fn parses_elseif_without_a_trailing_else() {
1750 let nodes = parse("@if(a) X @elseif(b) Y @endif").unwrap();
1754 assert_eq!(
1755 nodes,
1756 vec![Node::If {
1757 cond: "a".to_string(),
1758 then_branch: vec![Node::Text(" X ".to_string())],
1759 else_branch: vec![Node::If {
1760 cond: "b".to_string(),
1761 then_branch: vec![Node::Text(" Y ".to_string())],
1762 else_branch: vec![],
1763 }],
1764 }]
1765 );
1766 }
1767
1768 #[test]
1769 fn parses_elseif_with_a_trailing_else() {
1770 let nodes = parse("@if(a) X @elseif(b) Y @else Z @endif").unwrap();
1771 assert_eq!(
1772 nodes,
1773 vec![Node::If {
1774 cond: "a".to_string(),
1775 then_branch: vec![Node::Text(" X ".to_string())],
1776 else_branch: vec![Node::If {
1777 cond: "b".to_string(),
1778 then_branch: vec![Node::Text(" Y ".to_string())],
1779 else_branch: vec![Node::Text(" Z ".to_string())],
1780 }],
1781 }]
1782 );
1783 }
1784
1785 #[test]
1786 fn parses_a_chain_of_multiple_elseifs() {
1787 let nodes = parse("@if(a) X @elseif(b) Y @elseif(c) Z @else W @endif").unwrap();
1788 assert_eq!(
1789 nodes,
1790 vec![Node::If {
1791 cond: "a".to_string(),
1792 then_branch: vec![Node::Text(" X ".to_string())],
1793 else_branch: vec![Node::If {
1794 cond: "b".to_string(),
1795 then_branch: vec![Node::Text(" Y ".to_string())],
1796 else_branch: vec![Node::If {
1797 cond: "c".to_string(),
1798 then_branch: vec![Node::Text(" Z ".to_string())],
1799 else_branch: vec![Node::Text(" W ".to_string())],
1800 }],
1801 }],
1802 }]
1803 );
1804 }
1805
1806 #[test]
1807 fn parses_a_chain_of_multiple_elseifs_with_no_trailing_else() {
1808 let nodes = parse("@if(a) X @elseif(b) Y @elseif(c) Z @endif").unwrap();
1809 assert_eq!(
1810 nodes,
1811 vec![Node::If {
1812 cond: "a".to_string(),
1813 then_branch: vec![Node::Text(" X ".to_string())],
1814 else_branch: vec![Node::If {
1815 cond: "b".to_string(),
1816 then_branch: vec![Node::Text(" Y ".to_string())],
1817 else_branch: vec![Node::If {
1818 cond: "c".to_string(),
1819 then_branch: vec![Node::Text(" Z ".to_string())],
1820 else_branch: vec![],
1821 }],
1822 }],
1823 }]
1824 );
1825 }
1826
1827 #[test]
1828 fn stray_elseif_at_top_level_is_a_clear_error() {
1829 let err = parse("hi @elseif(x) there @endif").unwrap_err();
1830 assert!(err.to_string().contains("no matching opening directive"));
1831 }
1832
1833 #[test]
1834 fn if_condition_handles_nested_parens_and_strings() {
1835 let nodes = parse(r#"@if(user.name == "a)b") x @endif"#).unwrap();
1836 let Node::If { cond, .. } = &nodes[0] else {
1837 panic!("expected If node");
1838 };
1839 assert_eq!(cond, r#"user.name == "a)b""#);
1840 }
1841
1842 #[test]
1843 fn parses_foreach() {
1844 let nodes = parse("@foreach(post in posts){{ post }}@endforeach").unwrap();
1845 assert_eq!(
1846 nodes,
1847 vec![Node::Foreach {
1848 binding: "post".to_string(),
1849 iter: "posts".to_string(),
1850 body: vec![Node::Interpolate {
1851 expr: "post".to_string(),
1852 escape: true
1853 }],
1854 }]
1855 );
1856 }
1857
1858 #[test]
1859 fn parses_foreach_with_a_keyed_tuple_binding() {
1860 let nodes =
1861 parse("@foreach((key, item) in items.iter().enumerate()){{ key }}@endforeach").unwrap();
1862 assert_eq!(
1863 nodes,
1864 vec![Node::Foreach {
1865 binding: "(key, item)".to_string(),
1866 iter: "items.iter().enumerate()".to_string(),
1867 body: vec![Node::Interpolate {
1868 expr: "key".to_string(),
1869 escape: true
1870 }],
1871 }]
1872 );
1873 }
1874
1875 #[test]
1876 fn parses_extends_section_yield() {
1877 let nodes = parse("@extends('layouts.app')@section('content')hi@endsection").unwrap();
1878 assert_eq!(
1879 nodes,
1880 vec![
1881 Node::Extends("layouts.app".to_string()),
1882 Node::Section {
1883 name: "content".to_string(),
1884 body: vec![Node::Text("hi".to_string())],
1885 },
1886 ]
1887 );
1888
1889 let nodes = parse("@yield('content')").unwrap();
1890 assert_eq!(nodes, vec![Node::Yield("content".to_string())]);
1891 }
1892
1893 #[test]
1894 fn parses_push_and_stack() {
1895 let nodes = parse("@push('scripts')<script></script>@endpush").unwrap();
1896 assert_eq!(
1897 nodes,
1898 vec![Node::Push {
1899 name: "scripts".to_string(),
1900 body: vec![Node::Text("<script></script>".to_string())],
1901 }]
1902 );
1903
1904 let nodes = parse("@stack('scripts')").unwrap();
1905 assert_eq!(nodes, vec![Node::Stack("scripts".to_string())]);
1906 }
1907
1908 #[test]
1909 fn stray_endpush_without_a_matching_push_is_a_clear_error() {
1910 let err = parse("hi @endpush there").unwrap_err();
1911 assert!(err.to_string().contains("no matching opening directive"));
1912 }
1913
1914 #[test]
1915 fn parses_csrf() {
1916 let nodes = parse("<form>@csrf</form>").unwrap();
1917 assert_eq!(
1918 nodes,
1919 vec![
1920 Node::Text("<form>".to_string()),
1921 Node::Csrf,
1922 Node::Text("</form>".to_string()),
1923 ]
1924 );
1925 }
1926
1927 #[test]
1928 fn mismatched_closer_is_a_clear_error() {
1929 let err = parse("@if(x) hi @endforeach").unwrap_err();
1930 let message = err.to_string();
1931 assert!(message.contains("@endif"), "message was: {message}");
1932 assert!(
1933 message.contains("found @endforeach"),
1934 "message was: {message}"
1935 );
1936 }
1937
1938 #[test]
1939 fn unclosed_directive_is_a_clear_error() {
1940 let err = parse("@if(x) hi").unwrap_err();
1941 assert!(err.to_string().contains("unexpected end of template"));
1942 }
1943
1944 #[test]
1945 fn stray_closer_at_top_level_is_a_clear_error() {
1946 let err = parse("hi @endif").unwrap_err();
1947 assert!(err.to_string().contains("no matching opening directive"));
1948 }
1949
1950 #[test]
1951 fn parses_global_placeholder() {
1952 let nodes = parse("<title>@global(title)</title>").unwrap();
1953 assert_eq!(
1954 nodes,
1955 vec![
1956 Node::Text("<title>".to_string()),
1957 Node::Global {
1958 name: "title".to_string(),
1959 fallback: None,
1960 },
1961 Node::Text("</title>".to_string()),
1962 ]
1963 );
1964 }
1965
1966 #[test]
1967 fn parses_global_placeholder_with_a_fallback() {
1968 let nodes = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
1969 assert_eq!(
1970 nodes,
1971 vec![
1972 Node::Text("<title>".to_string()),
1973 Node::Global {
1974 name: "title".to_string(),
1975 fallback: Some("\"Larust\"".to_string()),
1976 },
1977 Node::Text("</title>".to_string()),
1978 ]
1979 );
1980 }
1981
1982 #[test]
1983 fn global_fallback_can_be_an_arbitrary_expression_with_nested_parens_and_strings() {
1984 let nodes = parse(r#"@global(title, default_title(page, "(fallback)"))"#).unwrap();
1985 assert_eq!(
1986 nodes,
1987 vec![Node::Global {
1988 name: "title".to_string(),
1989 fallback: Some(r#"default_title(page, "(fallback)")"#.to_string()),
1990 }]
1991 );
1992 }
1993
1994 #[test]
1995 fn missing_comma_or_close_paren_in_global_is_a_clear_error() {
1996 let err = parse("@global(title 'oops')").unwrap_err();
1997 assert!(
1998 err.to_string().contains("expected ',' or ')'"),
1999 "message was: {err}"
2000 );
2001 }
2002
2003 #[test]
2004 fn parses_globals_block_with_multiple_assignments() {
2005 let nodes = parse(
2006 "@globals\n\
2007 title = \"My Page\"\n\
2008 canonical = \"https://example.com\"\n\
2009 @endglobals",
2010 )
2011 .unwrap();
2012 assert_eq!(
2013 nodes,
2014 vec![Node::Globals(vec![
2015 GlobalEntry {
2016 name: "title".to_string(),
2017 expr: "\"My Page\"".to_string(),
2018 persist: false,
2019 },
2020 GlobalEntry {
2021 name: "canonical".to_string(),
2022 expr: "\"https://example.com\"".to_string(),
2023 persist: false,
2024 },
2025 ])]
2026 );
2027 }
2028
2029 #[test]
2030 fn globals_block_skips_blank_lines() {
2031 let nodes = parse("@globals\n\ntitle = \"x\"\n\n@endglobals").unwrap();
2032 assert_eq!(
2033 nodes,
2034 vec![Node::Globals(vec![GlobalEntry {
2035 name: "title".to_string(),
2036 expr: "\"x\"".to_string(),
2037 persist: false,
2038 }])]
2039 );
2040 }
2041
2042 #[test]
2043 fn globals_assignment_with_equality_operator_in_expression() {
2044 let nodes = parse("@globals\nactive = state == \"open\"\n@endglobals").unwrap();
2045 assert_eq!(
2046 nodes,
2047 vec![Node::Globals(vec![GlobalEntry {
2048 name: "active".to_string(),
2049 expr: "state == \"open\"".to_string(),
2050 persist: false,
2051 }])]
2052 );
2053 }
2054
2055 #[test]
2056 fn globals_assignment_with_equals_sign_inside_string_literal() {
2057 let nodes = parse("@globals\nnote = \"a=b\"\n@endglobals").unwrap();
2058 assert_eq!(
2059 nodes,
2060 vec![Node::Globals(vec![GlobalEntry {
2061 name: "note".to_string(),
2062 expr: "\"a=b\"".to_string(),
2063 persist: false,
2064 }])]
2065 );
2066 }
2067
2068 #[test]
2069 fn parses_a_persist_prefixed_globals_entry() {
2070 let nodes = parse("@globals\npersist theme = \"dark\"\n@endglobals").unwrap();
2071 assert_eq!(
2072 nodes,
2073 vec![Node::Globals(vec![GlobalEntry {
2074 name: "theme".to_string(),
2075 expr: "\"dark\"".to_string(),
2076 persist: true,
2077 }])]
2078 );
2079 }
2080
2081 #[test]
2082 fn persist_and_plain_entries_can_be_mixed_in_the_same_block() {
2083 let nodes = parse(
2084 "@globals\n\
2085 menuvisible = \"false\"\n\
2086 persist theme = \"dark\"\n\
2087 @endglobals",
2088 )
2089 .unwrap();
2090 assert_eq!(
2091 nodes,
2092 vec![Node::Globals(vec![
2093 GlobalEntry {
2094 name: "menuvisible".to_string(),
2095 expr: "\"false\"".to_string(),
2096 persist: false,
2097 },
2098 GlobalEntry {
2099 name: "theme".to_string(),
2100 expr: "\"dark\"".to_string(),
2101 persist: true,
2102 },
2103 ])]
2104 );
2105 }
2106
2107 #[test]
2108 fn persist_entrys_name_is_still_validated() {
2109 let err = parse("@globals\npersist not valid = \"x\"\n@endglobals").unwrap_err();
2110 assert!(
2111 err.to_string().contains("invalid variable name"),
2112 "message was: {err}"
2113 );
2114 }
2115
2116 #[test]
2117 fn malformed_globals_line_is_a_clear_error() {
2118 let err = parse("@globals\nnot an assignment\n@endglobals").unwrap_err();
2119 assert!(
2120 err.to_string().contains("expected `name = expression`"),
2121 "message was: {err}"
2122 );
2123 }
2124
2125 #[test]
2126 fn unterminated_globals_block_is_a_clear_error() {
2127 let err = parse("@globals\ntitle = \"x\"").unwrap_err();
2128 assert!(err.to_string().contains("unterminated @globals block"));
2129 }
2130
2131 #[test]
2132 fn globals_assignment_with_an_invalid_name_is_a_clear_error() {
2133 let err = parse("@globals\nmy title = \"x\"\n@endglobals").unwrap_err();
2139 assert!(
2140 err.to_string().contains("invalid variable name"),
2141 "message was: {err}"
2142 );
2143 }
2144
2145 #[test]
2146 fn stray_endglobals_at_top_level_is_a_clear_error() {
2147 let err = parse("hi @endglobals there").unwrap_err();
2148 assert!(err.to_string().contains("no matching opening directive"));
2149 }
2150
2151 #[test]
2152 fn parses_wire_with_no_props() {
2153 let nodes = parse("@wire('search-box')").unwrap();
2154 assert_eq!(
2155 nodes,
2156 vec![Node::Wire {
2157 name: "search-box".to_string(),
2158 props: vec![],
2159 }]
2160 );
2161 }
2162
2163 #[test]
2164 fn parses_wire_with_props() {
2165 let nodes = parse(r#"@wire('search-box', { query: "", limit: 10 })"#).unwrap();
2166 assert_eq!(
2167 nodes,
2168 vec![Node::Wire {
2169 name: "search-box".to_string(),
2170 props: vec![
2171 ("query".to_string(), "\"\"".to_string()),
2172 ("limit".to_string(), "10".to_string()),
2173 ],
2174 }]
2175 );
2176 }
2177
2178 #[test]
2179 fn wire_props_handle_nested_parens_braces_brackets_and_strings() {
2180 let nodes = parse(
2181 r#"@wire('widget', { items: vec![1, 2], meta: Foo { a: 1 }, label: default_label("(x)") })"#,
2182 )
2183 .unwrap();
2184 assert_eq!(
2185 nodes,
2186 vec![Node::Wire {
2187 name: "widget".to_string(),
2188 props: vec![
2189 ("items".to_string(), "vec![1, 2]".to_string()),
2190 ("meta".to_string(), "Foo { a: 1 }".to_string()),
2191 ("label".to_string(), r#"default_label("(x)")"#.to_string()),
2192 ],
2193 }]
2194 );
2195 }
2196
2197 #[test]
2198 fn wire_props_handle_a_trailing_comma() {
2199 let nodes = parse(r#"@wire('widget', { query: "x", })"#).unwrap();
2200 assert_eq!(
2201 nodes,
2202 vec![Node::Wire {
2203 name: "widget".to_string(),
2204 props: vec![("query".to_string(), "\"x\"".to_string())],
2205 }]
2206 );
2207 }
2208
2209 #[test]
2210 fn wire_with_empty_props_object() {
2211 let nodes = parse("@wire('widget', {})").unwrap();
2212 assert_eq!(
2213 nodes,
2214 vec![Node::Wire {
2215 name: "widget".to_string(),
2216 props: vec![],
2217 }]
2218 );
2219 }
2220
2221 #[test]
2222 fn missing_prop_colon_is_a_clear_error() {
2223 let err = parse("@wire('widget', { query })").unwrap_err();
2224 assert!(
2225 err.to_string().contains("expected `name: expression`"),
2226 "message was: {err}"
2227 );
2228 }
2229
2230 #[test]
2231 fn missing_prop_value_is_a_clear_error() {
2232 let err = parse("@wire('widget', { query: })").unwrap_err();
2233 assert!(
2234 err.to_string().contains("expected an expression after ':'"),
2235 "message was: {err}"
2236 );
2237 }
2238
2239 #[test]
2240 fn invalid_prop_name_is_a_clear_error() {
2241 let err = parse(r#"@wire('widget', { "query": "x" })"#).unwrap_err();
2242 assert!(
2243 err.to_string().contains("invalid prop name"),
2244 "message was: {err}"
2245 );
2246 }
2247
2248 #[test]
2249 fn unterminated_wire_props_is_a_clear_error() {
2250 let err = parse("@wire('widget', { query: \"x\"").unwrap_err();
2251 assert!(
2252 err.to_string().contains("unterminated '{' in @wire"),
2253 "message was: {err}"
2254 );
2255 }
2256
2257 #[test]
2258 fn missing_comma_or_close_paren_in_wire_is_a_clear_error() {
2259 let err = parse("@wire('widget' oops)").unwrap_err();
2260 assert!(
2261 err.to_string().contains("expected ',' or ')'"),
2262 "message was: {err}"
2263 );
2264 }
2265
2266 #[test]
2267 fn parses_larustscripts() {
2268 let nodes = parse("<body>@larustscripts</body>").unwrap();
2269 assert_eq!(
2270 nodes,
2271 vec![
2272 Node::Text("<body>".to_string()),
2273 Node::LarustScripts,
2274 Node::Text("</body>".to_string()),
2275 ]
2276 );
2277 }
2278
2279 #[test]
2280 fn parses_loadonce() {
2281 let nodes = parse("@loadonce<script></script>@endloadonce").unwrap();
2282 assert_eq!(
2283 nodes,
2284 vec![Node::LoadOnce(vec![Node::Text(
2285 "<script></script>".to_string()
2286 )])]
2287 );
2288 }
2289
2290 #[test]
2291 fn loadonce_can_contain_ordinary_directives() {
2292 let nodes = parse("@loadonce@if(ok)yes@endif@endloadonce").unwrap();
2293 assert_eq!(
2294 nodes,
2295 vec![Node::LoadOnce(vec![Node::If {
2296 cond: "ok".to_string(),
2297 then_branch: vec![Node::Text("yes".to_string())],
2298 else_branch: vec![],
2299 }])]
2300 );
2301 }
2302
2303 #[test]
2304 fn unclosed_loadonce_is_a_clear_error() {
2305 let err = parse("@loadonce<script></script>").unwrap_err();
2306 assert!(err.to_string().contains("unexpected end of template"));
2307 }
2308
2309 #[test]
2310 fn stray_endloadonce_at_top_level_is_a_clear_error() {
2311 let err = parse("hi @endloadonce there").unwrap_err();
2312 assert!(err.to_string().contains("no matching opening directive"));
2313 }
2314
2315 #[test]
2316 fn parses_resource_with_no_props_and_a_slot() {
2317 let nodes = parse("@resource('components.panel')<p>hi</p>@endresource").unwrap();
2318 assert_eq!(
2319 nodes,
2320 vec![Node::Resource {
2321 name: "components.panel".to_string(),
2322 props: vec![],
2323 slot: vec![Node::Text("<p>hi</p>".to_string())],
2324 }]
2325 );
2326 }
2327
2328 #[test]
2329 fn parses_resource_with_props_and_an_empty_slot() {
2330 let nodes =
2331 parse(r#"@resource('components.badge', { label: "New" })@endresource"#).unwrap();
2332 assert_eq!(
2333 nodes,
2334 vec![Node::Resource {
2335 name: "components.badge".to_string(),
2336 props: vec![("label".to_string(), "\"New\"".to_string())],
2337 slot: vec![],
2338 }]
2339 );
2340 }
2341
2342 #[test]
2343 fn resource_slot_can_contain_ordinary_directives() {
2344 let nodes = parse("@resource('components.panel')@if(ok)yes@endif@endresource").unwrap();
2345 assert_eq!(
2346 nodes,
2347 vec![Node::Resource {
2348 name: "components.panel".to_string(),
2349 props: vec![],
2350 slot: vec![Node::If {
2351 cond: "ok".to_string(),
2352 then_branch: vec![Node::Text("yes".to_string())],
2353 else_branch: vec![],
2354 }],
2355 }]
2356 );
2357 }
2358
2359 #[test]
2360 fn unclosed_resource_is_a_clear_error() {
2361 let err = parse("@resource('components.panel')<p>hi</p>").unwrap_err();
2362 assert!(err.to_string().contains("unexpected end of template"));
2363 }
2364
2365 #[test]
2366 fn stray_endresource_at_top_level_is_a_clear_error() {
2367 let err = parse("hi @endresource there").unwrap_err();
2368 assert!(err.to_string().contains("no matching opening directive"));
2369 }
2370
2371 #[test]
2372 fn missing_comma_or_close_paren_in_resource_is_a_clear_error() {
2373 let err = parse("@resource('panel' oops)@endresource").unwrap_err();
2374 assert!(
2375 err.to_string().contains("expected ',' or ')'"),
2376 "message was: {err}"
2377 );
2378 }
2379
2380 #[test]
2381 fn parses_live_with_a_string_literal_channel() {
2382 let nodes = parse("@live('posts.count')<span>5</span>@endlive").unwrap();
2383 assert_eq!(
2384 nodes,
2385 vec![Node::Live {
2386 channel: "'posts.count'".to_string(),
2387 body: vec![Node::Text("<span>5</span>".to_string())],
2388 }]
2389 );
2390 }
2391
2392 #[test]
2393 fn live_channel_can_be_an_arbitrary_expression() {
2394 let nodes = parse(r#"@live(format!("post.{}.comments", post.id))hi@endlive"#).unwrap();
2399 assert_eq!(
2400 nodes,
2401 vec![Node::Live {
2402 channel: r#"format!("post.{}.comments", post.id)"#.to_string(),
2403 body: vec![Node::Text("hi".to_string())],
2404 }]
2405 );
2406 }
2407
2408 #[test]
2409 fn live_body_can_contain_ordinary_directives() {
2410 let nodes = parse("@live('c')@if(ok)yes@endif@endlive").unwrap();
2411 assert_eq!(
2412 nodes,
2413 vec![Node::Live {
2414 channel: "'c'".to_string(),
2415 body: vec![Node::If {
2416 cond: "ok".to_string(),
2417 then_branch: vec![Node::Text("yes".to_string())],
2418 else_branch: vec![],
2419 }],
2420 }]
2421 );
2422 }
2423
2424 #[test]
2425 fn unclosed_live_is_a_clear_error() {
2426 let err = parse("@live('c')<p>hi</p>").unwrap_err();
2427 assert!(err.to_string().contains("unexpected end of template"));
2428 }
2429
2430 #[test]
2431 fn stray_endlive_at_top_level_is_a_clear_error() {
2432 let err = parse("hi @endlive there").unwrap_err();
2433 assert!(err.to_string().contains("no matching opening directive"));
2434 }
2435
2436 #[test]
2437 fn unterminated_paren_in_live_channel_is_a_clear_error() {
2438 let err = parse("@live('c'").unwrap_err();
2439 assert!(err.to_string().contains("unterminated '(' in directive"));
2440 }
2441
2442 #[test]
2443 fn parses_can_with_no_else() {
2444 let nodes = parse("@can(Permission::EditPosts)<b>yes</b>@endcan").unwrap();
2445 assert_eq!(
2446 nodes,
2447 vec![Node::Can {
2448 permission: "Permission::EditPosts".to_string(),
2449 then_branch: vec![Node::Text("<b>yes</b>".to_string())],
2450 else_branch: vec![],
2451 }]
2452 );
2453 }
2454
2455 #[test]
2456 fn parses_can_with_an_else_branch() {
2457 let nodes = parse("@can(Permission::EditPosts)yes@else no@endcan").unwrap();
2458 assert_eq!(
2459 nodes,
2460 vec![Node::Can {
2461 permission: "Permission::EditPosts".to_string(),
2462 then_branch: vec![Node::Text("yes".to_string())],
2463 else_branch: vec![Node::Text(" no".to_string())],
2464 }]
2465 );
2466 }
2467
2468 #[test]
2469 fn parses_role_with_no_else() {
2470 let nodes = parse("@role(Role::Admin)<b>admin</b>@endrole").unwrap();
2471 assert_eq!(
2472 nodes,
2473 vec![Node::Role {
2474 role: "Role::Admin".to_string(),
2475 then_branch: vec![Node::Text("<b>admin</b>".to_string())],
2476 else_branch: vec![],
2477 }]
2478 );
2479 }
2480
2481 #[test]
2482 fn parses_role_with_an_else_branch() {
2483 let nodes = parse("@role(Role::Admin)yes@else no@endrole").unwrap();
2484 assert_eq!(
2485 nodes,
2486 vec![Node::Role {
2487 role: "Role::Admin".to_string(),
2488 then_branch: vec![Node::Text("yes".to_string())],
2489 else_branch: vec![Node::Text(" no".to_string())],
2490 }]
2491 );
2492 }
2493
2494 #[test]
2495 fn can_body_can_contain_ordinary_directives() {
2496 let nodes = parse("@can(Permission::EditPosts)@if(ok)yes@endif@endcan").unwrap();
2497 assert_eq!(
2498 nodes,
2499 vec![Node::Can {
2500 permission: "Permission::EditPosts".to_string(),
2501 then_branch: vec![Node::If {
2502 cond: "ok".to_string(),
2503 then_branch: vec![Node::Text("yes".to_string())],
2504 else_branch: vec![],
2505 }],
2506 else_branch: vec![],
2507 }]
2508 );
2509 }
2510
2511 #[test]
2512 fn can_can_nest_inside_role() {
2513 let nodes =
2514 parse("@role(Role::Admin)@can(Permission::EditPosts)yes@endcan@endrole").unwrap();
2515 assert_eq!(
2516 nodes,
2517 vec![Node::Role {
2518 role: "Role::Admin".to_string(),
2519 then_branch: vec![Node::Can {
2520 permission: "Permission::EditPosts".to_string(),
2521 then_branch: vec![Node::Text("yes".to_string())],
2522 else_branch: vec![],
2523 }],
2524 else_branch: vec![],
2525 }]
2526 );
2527 }
2528
2529 #[test]
2530 fn unclosed_can_is_a_clear_error() {
2531 let err = parse("@can(Permission::EditPosts)<p>hi</p>").unwrap_err();
2532 assert!(err.to_string().contains("unexpected end of template"));
2533 }
2534
2535 #[test]
2536 fn mismatched_can_endrole_is_a_clear_error() {
2537 let err = parse("@can(Permission::EditPosts)hi@endrole").unwrap_err();
2538 assert!(
2539 err.to_string().contains("expected @else or @endcan"),
2540 "message was: {err}"
2541 );
2542 }
2543
2544 #[test]
2545 fn stray_endcan_at_top_level_is_a_clear_error() {
2546 let err = parse("hi @endcan there").unwrap_err();
2547 assert!(err.to_string().contains("no matching opening directive"));
2548 }
2549
2550 #[test]
2551 fn stray_endrole_at_top_level_is_a_clear_error() {
2552 let err = parse("hi @endrole there").unwrap_err();
2553 assert!(err.to_string().contains("no matching opening directive"));
2554 }
2555
2556 #[test]
2557 fn parses_spa_with_a_body() {
2558 let nodes = parse("@spa hi @endspa").unwrap();
2559 assert_eq!(nodes, vec![Node::Spa(vec![Node::Text(" hi ".to_string())])]);
2560 }
2561
2562 #[test]
2563 fn spa_can_wrap_a_yield() {
2564 let nodes = parse("@spa @yield('content') @endspa").unwrap();
2565 assert_eq!(
2566 nodes,
2567 vec![Node::Spa(vec![
2568 Node::Text(" ".to_string()),
2569 Node::Yield("content".to_string()),
2570 Node::Text(" ".to_string()),
2571 ])]
2572 );
2573 }
2574
2575 #[test]
2576 fn unclosed_spa_is_a_clear_error() {
2577 let err = parse("@spa hi").unwrap_err();
2578 assert!(err.to_string().contains("unexpected end of template"));
2579 }
2580
2581 #[test]
2582 fn mismatched_spa_closer_is_a_clear_error() {
2583 let err = parse("@spa hi @endforeach").unwrap_err();
2584 assert!(
2585 err.to_string().contains("expected @endspa"),
2586 "message was: {err}"
2587 );
2588 }
2589
2590 #[test]
2591 fn stray_endspa_at_top_level_is_a_clear_error() {
2592 let err = parse("hi @endspa there").unwrap_err();
2593 assert!(err.to_string().contains("no matching opening directive"));
2594 }
2595
2596 #[test]
2597 fn resource_tag_syntax_produces_the_same_node_the_directive_syntax_does() {
2598 let tag = parse(r#"<resource:components.badge label="New" />"#).unwrap();
2599 let directive =
2600 parse(r#"@resource('components.badge', { label: "New" })@endresource"#).unwrap();
2601 assert_eq!(tag, directive);
2602 }
2603
2604 #[test]
2605 fn parses_resource_tag_self_closing_with_no_props() {
2606 let nodes = parse("<resource:components.panel/>").unwrap();
2607 assert_eq!(
2608 nodes,
2609 vec![Node::Resource {
2610 name: "components.panel".to_string(),
2611 props: vec![],
2612 slot: vec![],
2613 }]
2614 );
2615 }
2616
2617 #[test]
2618 fn parses_resource_tag_self_closing_with_literal_and_dynamic_props() {
2619 let nodes = parse(r#"<resource:components.badge label="New" :count="unread" />"#).unwrap();
2620 assert_eq!(
2621 nodes,
2622 vec![Node::Resource {
2623 name: "components.badge".to_string(),
2624 props: vec![
2625 ("label".to_string(), "\"New\"".to_string()),
2626 ("count".to_string(), "unread".to_string()),
2627 ],
2628 slot: vec![],
2629 }]
2630 );
2631 }
2632
2633 #[test]
2634 fn parses_resource_tag_block_form_with_a_slot() {
2635 let nodes =
2636 parse(r#"<resource:components.panel title="Hi"><p>hi</p></resource:components.panel>"#)
2637 .unwrap();
2638 assert_eq!(
2639 nodes,
2640 vec![Node::Resource {
2641 name: "components.panel".to_string(),
2642 props: vec![("title".to_string(), "\"Hi\"".to_string())],
2643 slot: vec![Node::Text("<p>hi</p>".to_string())],
2644 }]
2645 );
2646 }
2647
2648 #[test]
2649 fn resource_tag_literal_attribute_with_a_double_quote_is_escaped() {
2650 let nodes = parse(r#"<resource:components.badge label='Say "hi"' />"#).unwrap();
2651 assert_eq!(
2652 nodes,
2653 vec![Node::Resource {
2654 name: "components.badge".to_string(),
2655 props: vec![("label".to_string(), "\"Say \\\"hi\\\"\"".to_string())],
2656 slot: vec![],
2657 }]
2658 );
2659 }
2660
2661 #[test]
2662 fn resource_tag_slot_can_contain_ordinary_directives() {
2663 let nodes =
2664 parse("<resource:components.panel>@if(ok)yes@endif</resource:components.panel>")
2665 .unwrap();
2666 assert_eq!(
2667 nodes,
2668 vec![Node::Resource {
2669 name: "components.panel".to_string(),
2670 props: vec![],
2671 slot: vec![Node::If {
2672 cond: "ok".to_string(),
2673 then_branch: vec![Node::Text("yes".to_string())],
2674 else_branch: vec![],
2675 }],
2676 }]
2677 );
2678 }
2679
2680 #[test]
2681 fn nested_resource_tags_parse_correctly() {
2682 let nodes = parse("<resource:a><resource:b>x</resource:b></resource:a>").unwrap();
2683 assert_eq!(
2684 nodes,
2685 vec![Node::Resource {
2686 name: "a".to_string(),
2687 props: vec![],
2688 slot: vec![Node::Resource {
2689 name: "b".to_string(),
2690 props: vec![],
2691 slot: vec![Node::Text("x".to_string())],
2692 }],
2693 }]
2694 );
2695 }
2696
2697 #[test]
2698 fn resource_tag_name_mismatch_between_open_and_close_is_a_clear_error() {
2699 let err = parse("<resource:a>hi</resource:b>").unwrap_err();
2700 let message = err.to_string();
2701 assert!(
2702 message.contains("expected </resource:a>"),
2703 "message was: {message}"
2704 );
2705 assert!(
2706 message.contains("found </resource:b>"),
2707 "message was: {message}"
2708 );
2709 }
2710
2711 #[test]
2712 fn unclosed_resource_tag_is_a_clear_error() {
2713 let err = parse("<resource:a><p>hi</p>").unwrap_err();
2714 assert!(err.to_string().contains("unexpected end of template"));
2715 }
2716
2717 #[test]
2718 fn stray_closing_resource_tag_at_top_level_is_a_clear_error() {
2719 let err = parse("hi </resource:a> there").unwrap_err();
2720 let message = err.to_string();
2721 assert!(
2722 message.contains("no matching <resource:a>"),
2723 "message was: {message}"
2724 );
2725 }
2726
2727 #[test]
2728 fn resource_tag_with_no_name_is_a_clear_error() {
2729 let err = parse("<resource:/>").unwrap_err();
2730 assert!(
2731 err.to_string()
2732 .contains("expected a name after '<resource:'"),
2733 "message was: {err}"
2734 );
2735 }
2736
2737 #[test]
2738 fn wire_tag_syntax_produces_the_same_node_the_directive_syntax_does() {
2739 let tag = parse(r#"<wire:search-box query="" :limit="10" />"#).unwrap();
2740 let directive = parse(r#"@wire('search-box', { query: "", limit: 10 })"#).unwrap();
2741 assert_eq!(tag, directive);
2742 }
2743
2744 #[test]
2745 fn parses_wire_tag_with_no_props() {
2746 let nodes = parse("<wire:search-box/>").unwrap();
2747 assert_eq!(
2748 nodes,
2749 vec![Node::Wire {
2750 name: "search-box".to_string(),
2751 props: vec![],
2752 }]
2753 );
2754 }
2755
2756 #[test]
2757 fn parses_wire_tag_with_literal_and_dynamic_props() {
2758 let nodes = parse(r#"<wire:post-form title="Untitled" :post_id="post.id" />"#).unwrap();
2759 assert_eq!(
2760 nodes,
2761 vec![Node::Wire {
2762 name: "post-form".to_string(),
2763 props: vec![
2764 ("title".to_string(), "\"Untitled\"".to_string()),
2765 ("post_id".to_string(), "post.id".to_string()),
2766 ],
2767 }]
2768 );
2769 }
2770
2771 #[test]
2772 fn wire_tag_without_self_close_is_a_clear_error() {
2773 let err = parse("<wire:search-box>").unwrap_err();
2774 assert!(
2775 err.to_string().contains("must be self-closing"),
2776 "message was: {err}"
2777 );
2778 }
2779
2780 #[test]
2781 fn wire_tag_with_no_name_is_a_clear_error() {
2782 let err = parse("<wire:/>").unwrap_err();
2783 assert!(
2784 err.to_string().contains("expected a name after '<wire:'"),
2785 "message was: {err}"
2786 );
2787 }
2788
2789 #[test]
2790 fn wire_tag_unterminated_is_a_clear_error() {
2791 let err = parse("<wire:search-box").unwrap_err();
2792 assert!(
2793 err.to_string()
2794 .contains("expected '/>' to close <wire:search-box>"),
2795 "message was: {err}"
2796 );
2797 }
2798
2799 #[test]
2800 fn parses_trusted_rust_code_block() {
2801 assert_eq!(
2802 parse("@code let label = \"Hello\"; @endcode{{ label }}").unwrap(),
2803 vec![
2804 Node::Code(" let label = \"Hello\"; ".to_string()),
2805 Node::Interpolate {
2806 expr: "label".to_string(),
2807 escape: true,
2808 },
2809 ]
2810 );
2811 }
2812
2813 #[test]
2814 fn parses_a_code_block_immediately_followed_by_a_raw_interpolation() {
2815 assert_eq!(
2820 parse(
2821 "@code let __vitex_tags = larust_support::vitex::tags(&[\"a\"]); @endcode{!! __vitex_tags !!}"
2822 )
2823 .unwrap(),
2824 vec![
2825 Node::Code(
2826 " let __vitex_tags = larust_support::vitex::tags(&[\"a\"]); ".to_string()
2827 ),
2828 Node::Interpolate {
2829 expr: "__vitex_tags".to_string(),
2830 escape: false,
2831 },
2832 ]
2833 );
2834 }
2835
2836 #[test]
2837 fn parses_vitex_with_multiple_entries() {
2838 assert_eq!(
2842 parse("@vitex(['resources/css/app.min.css', 'resources/js/app.min.js'])").unwrap(),
2843 vec![Node::Vitex(vec![
2844 "resources/css/app.min.css".to_string(),
2845 "resources/js/app.min.js".to_string(),
2846 ])]
2847 );
2848 }
2849
2850 #[test]
2851 fn parses_js_with_a_simple_expression() {
2852 let nodes = parse("<script>const post = @js(post);</script>").unwrap();
2853 assert_eq!(
2854 nodes,
2855 vec![
2856 Node::Text("<script>const post = ".to_string()),
2857 Node::Js("post".to_string()),
2858 Node::Text(";</script>".to_string()),
2859 ]
2860 );
2861 }
2862
2863 #[test]
2864 fn js_expression_can_be_arbitrary_like_live_channel() {
2865 let nodes = parse(r#"@js(serde_json::json!({"id": post.id}))"#).unwrap();
2869 assert_eq!(
2870 nodes,
2871 vec![Node::Js(
2872 r#"serde_json::json!({"id": post.id})"#.to_string()
2873 )]
2874 );
2875 }
2876
2877 #[test]
2878 fn unterminated_paren_in_js_expression_is_a_clear_error() {
2879 let err = parse("@js(post").unwrap_err();
2880 assert!(err.to_string().contains("unterminated '(' in directive"));
2881 }
2882
2883 #[test]
2884 fn parses_vitex_with_a_single_double_quoted_entry() {
2885 assert_eq!(
2886 parse("@vitex([\"resources/js/app.js\"])").unwrap(),
2887 vec![Node::Vitex(vec!["resources/js/app.js".to_string()])]
2888 );
2889 }
2890
2891 #[test]
2892 fn parses_vitex_tolerating_a_trailing_comma() {
2893 assert_eq!(
2894 parse("@vitex(['a', 'b',])").unwrap(),
2895 vec![Node::Vitex(vec!["a".to_string(), "b".to_string()])]
2896 );
2897 }
2898
2899 #[test]
2900 fn rejects_vitex_with_an_empty_array() {
2901 let err = parse("@vitex([])").unwrap_err();
2902 assert!(
2903 err.to_string().contains("at least one entry"),
2904 "message was: {err}"
2905 );
2906 }
2907
2908 #[test]
2909 fn rejects_vitex_missing_the_array_brackets() {
2910 let err = parse("@vitex('resources/js/app.js')").unwrap_err();
2911 assert!(err.to_string().contains('['), "message was: {err}");
2912 }
2913}