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}
219
220fn next_marker(s: &str, in_script: bool) -> Option<(usize, MarkerKind)> {
221 let at = find_next_at_directive(s, in_script).map(|(p, kw)| (p, MarkerKind::At(kw)));
222 let comment = s.find("{{--").map(|p| (p, MarkerKind::Comment));
228 let raw = s.find("{!!").map(|p| (p, MarkerKind::Raw));
229 let esc = s.find("{{").map(|p| (p, MarkerKind::Escaped));
230 let resource_close = s
235 .find("</resource:")
236 .map(|p| (p, MarkerKind::ResourceTagClose));
237 let resource_open = s
238 .find("<resource:")
239 .map(|p| (p, MarkerKind::ResourceTagOpen));
240 let wire_open = s.find("<wire:").map(|p| (p, MarkerKind::WireTagOpen));
241
242 [
243 at,
244 comment,
245 raw,
246 esc,
247 resource_close,
248 resource_open,
249 wire_open,
250 ]
251 .into_iter()
252 .flatten()
253 .min_by_key(|(p, _)| *p)
254}
255
256fn find_next_at_directive(s: &str, baseline_in_script: bool) -> Option<(usize, &'static str)> {
274 let mut search_from = 0;
275 while let Some(rel) = s[search_from..].find('@') {
276 let pos = search_from + rel;
277 let in_script = script_state_after(&s[..pos], baseline_in_script);
278 if is_inside_a_comment(s, pos, in_script) {
279 search_from = pos + 1;
280 continue;
281 }
282 let after = &s[pos + 1..];
283 let hit = KEYWORDS.iter().find(|kw| {
284 after.starts_with(**kw)
285 && after[kw.len()..]
286 .chars()
287 .next()
288 .is_none_or(|c| !c.is_alphanumeric() && c != '_')
289 });
290 if let Some(kw) = hit {
291 return Some((pos, kw));
292 }
293 search_from = pos + 1;
294 }
295 None
296}
297
298fn is_inside_a_comment(s: &str, pos: usize, in_script: bool) -> bool {
315 #[derive(Clone, Copy, PartialEq)]
316 enum State {
317 Normal,
318 HtmlComment,
319 LineComment,
320 BlockComment,
321 JsString(char),
322 }
323
324 let mut rest = &s[..pos];
325 let mut state = State::Normal;
326
327 while !rest.is_empty() {
328 match state {
329 State::Normal => {
330 let html_comment = rest.find("<!--").map(|p| (p, State::HtmlComment, 4));
331 let line_comment = in_script
332 .then(|| rest.find("//"))
333 .flatten()
334 .map(|p| (p, State::LineComment, 2));
335 let block_comment = in_script
336 .then(|| rest.find("/*"))
337 .flatten()
338 .map(|p| (p, State::BlockComment, 2));
339 let string_start = in_script
340 .then(|| rest.find(['\'', '"', '`']))
341 .flatten()
342 .map(|p| {
343 let quote = rest[p..].chars().next().expect("just found at this offset");
344 (p, State::JsString(quote), 1)
345 });
346
347 let Some((found_at, new_state, skip)) =
348 [html_comment, line_comment, block_comment, string_start]
349 .into_iter()
350 .flatten()
351 .min_by_key(|(p, _, _)| *p)
352 else {
353 break; };
355 rest = &rest[found_at + skip..];
356 state = new_state;
357 }
358 State::HtmlComment => match rest.find("-->") {
359 Some(p) => {
360 rest = &rest[p + 3..];
361 state = State::Normal;
362 }
363 None => return true, },
365 State::LineComment => match rest.find('\n') {
366 Some(p) => {
367 rest = &rest[p + 1..];
368 state = State::Normal;
369 }
370 None => return true,
371 },
372 State::BlockComment => match rest.find("*/") {
373 Some(p) => {
374 rest = &rest[p + 2..];
375 state = State::Normal;
376 }
377 None => return true,
378 },
379 State::JsString(quote) => {
380 let mut search = rest;
381 loop {
382 match search.find(quote) {
383 Some(p) if p > 0 && search.as_bytes()[p - 1] == b'\\' => {
388 search = &search[p + 1..];
389 }
390 Some(p) => {
391 rest = &search[p + 1..];
392 state = State::Normal;
393 break;
394 }
395 None => return true,
396 }
397 }
398 }
399 }
400 }
401
402 matches!(
403 state,
404 State::HtmlComment | State::LineComment | State::BlockComment
405 )
406}
407
408fn read_closer(cur: &mut Cursor, kw: &'static str) -> Result<Closer, ParseError> {
413 let elseif_cond = if kw == "elseif" {
414 Some(parse_paren_expr(cur)?)
415 } else {
416 None
417 };
418 Ok(Closer {
419 tag: kw,
420 elseif_cond,
421 resource_tag_name: None,
422 })
423}
424
425fn parse_nodes(cur: &mut Cursor) -> Result<(Vec<Node>, Option<Closer>), ParseError> {
426 let mut nodes = Vec::new();
427
428 loop {
429 match next_marker(cur.rest(), cur.in_script) {
430 None => {
431 let text = cur.rest();
432 if !text.is_empty() {
433 cur.update_script_state(text);
434 nodes.push(Node::Text(text.to_string()));
435 }
436 cur.advance(text.len());
437 return Ok((nodes, None));
438 }
439 Some((offset, kind)) => {
440 if offset > 0 {
441 let text = &cur.rest()[..offset];
442 cur.update_script_state(text);
443 nodes.push(Node::Text(text.to_string()));
444 }
445 cur.advance(offset);
446
447 match kind {
448 MarkerKind::Comment => {
462 parse_braces(cur, "{{--", "--}}")?;
463 }
464 MarkerKind::Escaped => {
465 let expr = parse_braces(cur, "{{", "}}")?;
466 nodes.push(Node::Interpolate { expr, escape: true });
467 }
468 MarkerKind::Raw => {
469 let expr = parse_braces(cur, "{!!", "!!}")?;
470 nodes.push(Node::Interpolate {
471 expr,
472 escape: false,
473 });
474 }
475 MarkerKind::ResourceTagOpen => {
476 nodes.push(parse_resource_tag(cur)?);
477 }
478 MarkerKind::WireTagOpen => {
479 nodes.push(parse_wire_tag(cur)?);
480 }
481 MarkerKind::ResourceTagClose => {
482 cur.advance("</resource:".len());
483 let name = scan_tag_name(cur, "resource")?;
484 skip_ws(cur);
485 expect_char(cur, '>')?;
486 return Ok((
487 nodes,
488 Some(Closer {
489 tag: "endresourcetag",
490 elseif_cond: None,
491 resource_tag_name: Some(name),
492 }),
493 ));
494 }
495 MarkerKind::At(kw) => {
496 cur.advance(1 + kw.len()); if CLOSERS.contains(&kw) {
499 return Ok((nodes, Some(read_closer(cur, kw)?)));
500 }
501
502 match kw {
503 "extends" => {
504 let name = parse_quoted_arg(cur)?;
505 nodes.push(Node::Extends(name));
506 }
507 "yield" => {
508 let name = parse_quoted_arg(cur)?;
509 nodes.push(Node::Yield(name));
510 }
511 "section" => {
512 let name = parse_quoted_arg(cur)?;
513 let (body, closer) = parse_nodes(cur)?;
514 expect_closer(closer, "endsection")?;
515 nodes.push(Node::Section { name, body });
516 }
517 "if" => {
518 let cond = parse_paren_expr(cur)?;
519 let (then_branch, else_branch) = parse_if_tail(cur)?;
520 nodes.push(Node::If {
521 cond,
522 then_branch,
523 else_branch,
524 });
525 }
526 "foreach" => {
527 let raw = parse_paren_expr(cur)?;
528 let (binding, iter) = split_foreach(&raw)?;
529 let (body, closer) = parse_nodes(cur)?;
530 expect_closer(closer, "endforeach")?;
531 nodes.push(Node::Foreach {
532 binding,
533 iter,
534 body,
535 });
536 }
537 "push" => {
538 let name = parse_quoted_arg(cur)?;
539 let (body, closer) = parse_nodes(cur)?;
540 expect_closer(closer, "endpush")?;
541 nodes.push(Node::Push { name, body });
542 }
543 "stack" => {
544 let name = parse_quoted_arg(cur)?;
545 nodes.push(Node::Stack(name));
546 }
547 "csrf" => {
548 nodes.push(Node::Csrf);
549 }
550 "global" => {
551 let (name, fallback) = parse_global_args(cur)?;
552 nodes.push(Node::Global { name, fallback });
553 }
554 "globals" => {
555 let entries = parse_globals_block(cur)?;
556 nodes.push(Node::Globals(entries));
557 }
558 "wire" => {
559 let (name, props) = parse_wire_args(cur)?;
560 nodes.push(Node::Wire { name, props });
561 }
562 "larustscripts" => {
563 nodes.push(Node::LarustScripts);
564 }
565 "loadonce" => {
566 let (body, closer) = parse_nodes(cur)?;
567 expect_closer(closer, "endloadonce")?;
568 nodes.push(Node::LoadOnce(body));
569 }
570 "resource" => {
571 let (name, props) = parse_resource_args(cur)?;
572 let (slot, closer) = parse_nodes(cur)?;
573 expect_closer(closer, "endresource")?;
574 nodes.push(Node::Resource { name, props, slot });
575 }
576 "live" => {
577 let channel = parse_paren_expr(cur)?;
578 let (body, closer) = parse_nodes(cur)?;
579 expect_closer(closer, "endlive")?;
580 nodes.push(Node::Live { channel, body });
581 }
582 "code" => nodes.push(Node::Code(parse_code_block(cur)?)),
583 "js" => {
584 let expr = parse_paren_expr(cur)?;
585 nodes.push(Node::Js(expr));
586 }
587 "vitex" => {
588 let entries = parse_vitex_args(cur)?;
589 nodes.push(Node::Vitex(entries));
590 }
591 "can" => {
592 let permission = parse_paren_expr(cur)?;
593 let (then_branch, else_branch) = parse_else_tail(cur, "endcan")?;
594 nodes.push(Node::Can {
595 permission,
596 then_branch,
597 else_branch,
598 });
599 }
600 "role" => {
601 let role = parse_paren_expr(cur)?;
602 let (then_branch, else_branch) = parse_else_tail(cur, "endrole")?;
603 nodes.push(Node::Role {
604 role,
605 then_branch,
606 else_branch,
607 });
608 }
609 "spa" => {
610 let (body, closer) = parse_nodes(cur)?;
611 expect_closer(closer, "endspa")?;
612 nodes.push(Node::Spa(body));
613 }
614 other => {
615 return Err(ParseError::new(format!("unknown directive @{other}")))
616 }
617 }
618 }
619 }
620 }
621 }
622 }
623}
624
625fn parse_code_block(cur: &mut Cursor) -> Result<String, ParseError> {
629 let rest = cur.rest();
630 let end = rest
631 .find("@endcode")
632 .ok_or_else(|| ParseError::new("unterminated @code block, expected @endcode"))?;
633 let code = rest[..end].to_string();
634 cur.advance(end + "@endcode".len());
635 Ok(code)
636}
637
638fn parse_if_tail(cur: &mut Cursor) -> Result<(Vec<Node>, Vec<Node>), ParseError> {
646 let (then_branch, closer) = parse_nodes(cur)?;
647 match closer {
648 Some(Closer { tag: "endif", .. }) => Ok((then_branch, Vec::new())),
649 Some(Closer { tag: "else", .. }) => {
650 let (else_branch, closer2) = parse_nodes(cur)?;
651 expect_closer(closer2, "endif")?;
652 Ok((then_branch, else_branch))
653 }
654 Some(Closer {
655 tag: "elseif",
656 elseif_cond: Some(cond),
657 ..
658 }) => {
659 let (nested_then, nested_else) = parse_if_tail(cur)?;
660 let else_branch = vec![Node::If {
661 cond,
662 then_branch: nested_then,
663 else_branch: nested_else,
664 }];
665 Ok((then_branch, else_branch))
666 }
667 other => Err(unexpected_closer(other, "@elseif, @else, or @endif")),
668 }
669}
670
671fn parse_else_tail(
680 cur: &mut Cursor,
681 end_tag: &'static str,
682) -> Result<(Vec<Node>, Vec<Node>), ParseError> {
683 let (then_branch, closer) = parse_nodes(cur)?;
684 match closer {
685 Some(Closer { tag, .. }) if tag == end_tag => Ok((then_branch, Vec::new())),
686 Some(Closer { tag: "else", .. }) => {
687 let (else_branch, closer2) = parse_nodes(cur)?;
688 expect_closer(closer2, end_tag)?;
689 Ok((then_branch, else_branch))
690 }
691 other => Err(unexpected_closer(other, &format!("@else or @{end_tag}"))),
692 }
693}
694
695fn parse_braces(cur: &mut Cursor, open: &str, close: &str) -> Result<String, ParseError> {
696 cur.advance(open.len());
697 let s = cur.rest();
698 let end = s
699 .find(close)
700 .ok_or_else(|| ParseError::new(format!("unterminated {open} ... {close}")))?;
701 let expr = s[..end].trim().to_string();
702 cur.advance(end + close.len());
703 Ok(expr)
704}
705
706fn parse_quoted_arg(cur: &mut Cursor) -> Result<String, ParseError> {
707 skip_ws(cur);
708 expect_char(cur, '(')?;
709 skip_ws(cur);
710 let value = parse_quoted_string(cur)?;
711 skip_ws(cur);
712 expect_char(cur, ')')?;
713 Ok(value)
714}
715
716fn parse_quoted_string(cur: &mut Cursor) -> Result<String, ParseError> {
723 let quote = expect_one_of(cur, &['\'', '"'])?;
724
725 let s = cur.rest();
726 let end = s
727 .find(quote)
728 .ok_or_else(|| ParseError::new("unterminated string in directive argument"))?;
729 let value = s[..end].to_string();
730 cur.advance(end + 1); Ok(value)
732}
733
734fn parse_vitex_args(cur: &mut Cursor) -> Result<Vec<String>, ParseError> {
743 skip_ws(cur);
744 expect_char(cur, '(')?;
745 skip_ws(cur);
746 expect_char(cur, '[')?;
747
748 let mut entries = Vec::new();
749 loop {
750 skip_ws(cur);
751 if cur.rest().starts_with(']') {
752 cur.advance(1);
753 break;
754 }
755 entries.push(parse_quoted_string(cur)?);
756 skip_ws(cur);
757 match cur.rest().chars().next() {
758 Some(',') => cur.advance(1),
759 Some(']') => {
760 cur.advance(1);
761 break;
762 }
763 _ => return Err(ParseError::new("expected ',' or ']' in @vitex([...])")),
764 }
765 }
766 skip_ws(cur);
767 expect_char(cur, ')')?;
768 if entries.is_empty() {
769 return Err(ParseError::new(
770 "@vitex([...]) needs at least one entry path",
771 ));
772 }
773 Ok(entries)
774}
775
776fn parse_global_args(cur: &mut Cursor) -> Result<(String, Option<String>), ParseError> {
786 skip_ws(cur);
787 expect_char(cur, '(')?;
788 skip_ws(cur);
789
790 let s = cur.rest();
791 let end = s
792 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
793 .unwrap_or(s.len());
794 if end == 0 {
795 return Err(ParseError::new("expected a variable name in @global(...)"));
796 }
797 let name = s[..end].to_string();
798 cur.advance(end);
799 skip_ws(cur);
800
801 match cur.rest().chars().next() {
802 Some(')') => {
803 cur.advance(1);
804 Ok((name, None))
805 }
806 Some(',') => {
807 cur.advance(1);
808 skip_ws(cur);
809 let fallback = scan_to_matching_close_paren(cur)?;
810 if fallback.is_empty() {
811 return Err(ParseError::new(
812 "expected a fallback expression after ',' in @global(...)",
813 ));
814 }
815 Ok((name, Some(fallback)))
816 }
817 Some(c) => Err(ParseError::new(format!(
818 "expected ',' or ')' in @global(...), found '{c}'"
819 ))),
820 None => Err(ParseError::new(
821 "expected ',' or ')' in @global(...), found end of template",
822 )),
823 }
824}
825
826fn parse_globals_block(cur: &mut Cursor) -> Result<Vec<GlobalEntry>, ParseError> {
833 let s = cur.rest();
834 let end = s
835 .find("@endglobals")
836 .ok_or_else(|| ParseError::new("unterminated @globals block, expected @endglobals"))?;
837 let block_text = &s[..end];
838 let entries = parse_globals_entries(block_text)?;
839 cur.advance(end + "@endglobals".len());
840 Ok(entries)
841}
842
843const PERSIST_PREFIX: &str = "persist ";
849
850fn parse_globals_entries(block_text: &str) -> Result<Vec<GlobalEntry>, ParseError> {
851 let mut entries = Vec::new();
852 for line in block_text.lines() {
853 let line = line.trim();
854 if line.is_empty() {
855 continue;
856 }
857 let (persist, line) = match line.strip_prefix(PERSIST_PREFIX) {
858 Some(rest) => (true, rest.trim_start()),
859 None => (false, line),
860 };
861 let split = find_assignment_split(line).ok_or_else(|| {
862 ParseError::new(format!(
863 "expected `name = expression` in @globals block, found: `{line}`"
864 ))
865 })?;
866 let name = line[..split].trim().to_string();
867 let expr = line[split + 1..].trim().to_string();
868 if !is_valid_global_name(&name) {
876 return Err(ParseError::new(format!(
877 "invalid variable name `{name}` in @globals block - only letters, digits, and \
878 underscores are allowed, matching @global(name)'s own placeholder syntax"
879 )));
880 }
881 if expr.is_empty() {
882 return Err(ParseError::new(format!(
883 "expected an expression after '=' in @globals block, found: `{line}`"
884 )));
885 }
886 entries.push(GlobalEntry {
887 name,
888 expr,
889 persist,
890 });
891 }
892 Ok(entries)
893}
894
895fn parse_wire_args(cur: &mut Cursor) -> Result<(String, Vec<(String, String)>), ParseError> {
900 parse_name_and_props_args(cur, "wire")
901}
902
903fn parse_resource_args(cur: &mut Cursor) -> Result<(String, Vec<(String, String)>), ParseError> {
910 parse_name_and_props_args(cur, "resource")
911}
912
913fn parse_name_and_props_args(
917 cur: &mut Cursor,
918 directive: &str,
919) -> Result<(String, Vec<(String, String)>), ParseError> {
920 skip_ws(cur);
921 expect_char(cur, '(')?;
922 skip_ws(cur);
923 let name = parse_quoted_string(cur)?;
924 skip_ws(cur);
925
926 match cur.rest().chars().next() {
927 Some(')') => {
928 cur.advance(1);
929 Ok((name, Vec::new()))
930 }
931 Some(',') => {
932 cur.advance(1);
933 skip_ws(cur);
934 expect_char(cur, '{')?;
935 let props = parse_prop_entries(cur)?;
936 skip_ws(cur);
937 expect_char(cur, ')')?;
938 Ok((name, props))
939 }
940 Some(c) => Err(ParseError::new(format!(
941 "expected ',' or ')' in @{directive}(...), found '{c}'"
942 ))),
943 None => Err(ParseError::new(format!(
944 "expected ',' or ')' in @{directive}(...), found end of template"
945 ))),
946 }
947}
948
949fn parse_resource_tag(cur: &mut Cursor) -> Result<Node, ParseError> {
966 cur.advance("<resource:".len());
967 let name = scan_tag_name(cur, "resource")?;
968 let props = parse_tag_attrs(cur, "resource")?;
969 skip_ws(cur);
970
971 if cur.rest().starts_with("/>") {
972 cur.advance(2);
973 return Ok(Node::Resource {
974 name,
975 props,
976 slot: Vec::new(),
977 });
978 }
979
980 expect_char(cur, '>')?;
981 let (slot, closer) = parse_nodes(cur)?;
982 match closer {
983 Some(Closer {
984 resource_tag_name: Some(found),
985 ..
986 }) if found == name => Ok(Node::Resource { name, props, slot }),
987 other => Err(unexpected_closer(other, &format!("</resource:{name}>"))),
995 }
996}
997
998fn parse_wire_tag(cur: &mut Cursor) -> Result<Node, ParseError> {
1007 cur.advance("<wire:".len());
1008 let name = scan_tag_name(cur, "wire")?;
1009 let props = parse_tag_attrs(cur, "wire")?;
1010 skip_ws(cur);
1011
1012 if cur.rest().starts_with("/>") {
1013 cur.advance(2);
1014 return Ok(Node::Wire { name, props });
1015 }
1016
1017 match cur.rest().chars().next() {
1018 Some('>') => Err(ParseError::new(format!(
1019 "<wire:{name}> must be self-closing ('/>') - it has no closing tag or slot, \
1020 unlike <resource:...>"
1021 ))),
1022 Some(c) => Err(ParseError::new(format!(
1023 "expected '/>' to close <wire:{name}>, found '{c}'"
1024 ))),
1025 None => Err(ParseError::new(format!(
1026 "expected '/>' to close <wire:{name}>, found end of template"
1027 ))),
1028 }
1029}
1030
1031fn scan_tag_name(cur: &mut Cursor, tag_prefix: &str) -> Result<String, ParseError> {
1038 let s = cur.rest();
1039 let end = s
1040 .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
1041 .unwrap_or(s.len());
1042 if end == 0 {
1043 return Err(ParseError::new(format!(
1044 "expected a name after '<{tag_prefix}:'"
1045 )));
1046 }
1047 let name = s[..end].to_string();
1048 cur.advance(end);
1049 Ok(name)
1050}
1051
1052fn parse_tag_attrs(
1057 cur: &mut Cursor,
1058 tag_prefix: &str,
1059) -> Result<Vec<(String, String)>, ParseError> {
1060 let mut props = Vec::new();
1061 loop {
1062 skip_ws(cur);
1063 match cur.rest().chars().next() {
1064 Some('/') | Some('>') | None => return Ok(props),
1065 _ => {}
1066 }
1067
1068 let dynamic = cur.rest().starts_with(':');
1069 if dynamic {
1070 cur.advance(1);
1071 }
1072
1073 let s = cur.rest();
1074 let end = s
1075 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
1076 .unwrap_or(s.len());
1077 if end == 0 {
1078 return Err(ParseError::new(format!(
1079 "expected an attribute name in <{tag_prefix}:...> tag"
1080 )));
1081 }
1082 let attr_name = s[..end].to_string();
1083 cur.advance(end);
1084 skip_ws(cur);
1085 expect_char(cur, '=')?;
1086 skip_ws(cur);
1087 let raw_value = parse_quoted_string(cur)?;
1088
1089 let expr = if dynamic {
1090 raw_value
1091 } else {
1092 literal_attr_to_rust_string(&raw_value)
1093 };
1094 props.push((attr_name, expr));
1095 }
1096}
1097
1098fn literal_attr_to_rust_string(raw: &str) -> String {
1103 let mut escaped = String::with_capacity(raw.len() + 2);
1104 escaped.push('"');
1105 for c in raw.chars() {
1106 match c {
1107 '"' => escaped.push_str("\\\""),
1108 '\\' => escaped.push_str("\\\\"),
1109 _ => escaped.push(c),
1110 }
1111 }
1112 escaped.push('"');
1113 escaped
1114}
1115
1116fn parse_prop_entries(cur: &mut Cursor) -> Result<Vec<(String, String)>, ParseError> {
1129 let s = cur.rest();
1130 let mut depth: i32 = 1;
1131 let mut end = None;
1132 let mut chars = s.char_indices().peekable();
1133
1134 while let Some((i, c)) = chars.next() {
1135 match c {
1136 '(' | '{' | '[' => depth += 1,
1137 ')' | '}' | ']' => {
1138 depth -= 1;
1139 if depth == 0 {
1140 end = Some(i);
1141 break;
1142 }
1143 }
1144 '"' | '\'' => {
1145 let quote = c;
1146 while let Some((_, ch)) = chars.next() {
1147 if ch == '\\' {
1148 chars.next();
1149 continue;
1150 }
1151 if ch == quote {
1152 break;
1153 }
1154 }
1155 }
1156 _ => {}
1157 }
1158 }
1159
1160 let end = end.ok_or_else(|| ParseError::new("unterminated '{' in @wire(...) props"))?;
1161 let body = &s[..end];
1162 cur.advance(end + 1);
1163
1164 split_prop_entries(body)
1165}
1166
1167fn split_prop_entries(body: &str) -> Result<Vec<(String, String)>, ParseError> {
1174 let mut entries = Vec::new();
1175 let mut depth: i32 = 0;
1176 let mut start = 0;
1177 let bytes = body.as_bytes();
1178 let mut i = 0;
1179
1180 while i < bytes.len() {
1181 match bytes[i] {
1182 b'(' | b'{' | b'[' => {
1183 depth += 1;
1184 i += 1;
1185 }
1186 b')' | b'}' | b']' => {
1187 depth -= 1;
1188 i += 1;
1189 }
1190 b'"' | b'\'' => {
1191 let quote = bytes[i];
1192 i += 1;
1193 while i < bytes.len() {
1194 if bytes[i] == b'\\' {
1195 i += 2;
1196 continue;
1197 }
1198 if bytes[i] == quote {
1199 i += 1;
1200 break;
1201 }
1202 i += 1;
1203 }
1204 }
1205 b',' if depth == 0 => {
1206 push_prop_entry(&mut entries, body[start..i].trim())?;
1207 start = i + 1;
1208 i += 1;
1209 }
1210 _ => i += 1,
1211 }
1212 }
1213 push_prop_entry(&mut entries, body[start..].trim())?;
1214
1215 Ok(entries)
1216}
1217
1218fn push_prop_entry(entries: &mut Vec<(String, String)>, entry: &str) -> Result<(), ParseError> {
1219 if entry.is_empty() {
1220 return Ok(());
1221 }
1222 let split = find_prop_colon(entry).ok_or_else(|| {
1223 ParseError::new(format!(
1224 "expected `name: expression` in @wire(...) props, found: `{entry}`"
1225 ))
1226 })?;
1227 let name = entry[..split].trim().to_string();
1228 let expr = entry[split + 1..].trim().to_string();
1229 if !is_valid_global_name(&name) {
1233 return Err(ParseError::new(format!(
1234 "invalid prop name `{name}` in @wire(...) - only letters, digits, and underscores \
1235 are allowed"
1236 )));
1237 }
1238 if expr.is_empty() {
1239 return Err(ParseError::new(format!(
1240 "expected an expression after ':' in @wire(...) props, found: `{entry}`"
1241 )));
1242 }
1243 entries.push((name, expr));
1244 Ok(())
1245}
1246
1247fn find_prop_colon(entry: &str) -> Option<usize> {
1256 let bytes = entry.as_bytes();
1257 let mut i = 0;
1258 while i < bytes.len() {
1259 match bytes[i] {
1260 b'"' | b'\'' => {
1261 let quote = bytes[i];
1262 i += 1;
1263 while i < bytes.len() {
1264 if bytes[i] == b'\\' {
1265 i += 2;
1266 continue;
1267 }
1268 if bytes[i] == quote {
1269 i += 1;
1270 break;
1271 }
1272 i += 1;
1273 }
1274 }
1275 b':' => return Some(i),
1276 _ => i += 1,
1277 }
1278 }
1279 None
1280}
1281
1282fn is_valid_global_name(name: &str) -> bool {
1287 !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
1288}
1289
1290fn find_assignment_split(line: &str) -> Option<usize> {
1297 let bytes = line.as_bytes();
1298 let mut i = 0;
1299 while i < bytes.len() {
1300 match bytes[i] {
1301 b'"' | b'\'' => {
1302 let quote = bytes[i];
1303 i += 1;
1304 while i < bytes.len() {
1305 if bytes[i] == b'\\' {
1306 i += 2;
1307 continue;
1308 }
1309 if bytes[i] == quote {
1310 i += 1;
1311 break;
1312 }
1313 i += 1;
1314 }
1315 }
1316 b'=' => {
1317 let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
1318 let next = bytes.get(i + 1).copied();
1319 let is_operator_char =
1320 |b: Option<u8>| matches!(b, Some(b'!') | Some(b'<') | Some(b'>') | Some(b'='));
1321 if !is_operator_char(prev) && next != Some(b'=') {
1322 return Some(i);
1323 }
1324 i += 1;
1325 }
1326 _ => i += 1,
1327 }
1328 }
1329 None
1330}
1331
1332fn parse_paren_expr(cur: &mut Cursor) -> Result<String, ParseError> {
1338 skip_ws(cur);
1339 expect_char(cur, '(')?;
1340 scan_to_matching_close_paren(cur)
1341}
1342
1343fn scan_to_matching_close_paren(cur: &mut Cursor) -> Result<String, ParseError> {
1350 let s = cur.rest();
1351 let mut depth: i32 = 1;
1352 let mut end = None;
1353 let mut chars = s.char_indices().peekable();
1354
1355 while let Some((i, c)) = chars.next() {
1356 match c {
1357 '(' => depth += 1,
1358 ')' => {
1359 depth -= 1;
1360 if depth == 0 {
1361 end = Some(i);
1362 break;
1363 }
1364 }
1365 '"' | '\'' => {
1366 let quote = c;
1367 while let Some((_, ch)) = chars.next() {
1368 if ch == '\\' {
1369 chars.next();
1370 continue;
1371 }
1372 if ch == quote {
1373 break;
1374 }
1375 }
1376 }
1377 _ => {}
1378 }
1379 }
1380
1381 let end = end.ok_or_else(|| ParseError::new("unterminated '(' in directive"))?;
1382 let expr = s[..end].trim().to_string();
1383 cur.advance(end + 1);
1384 Ok(expr)
1385}
1386
1387fn split_foreach(raw: &str) -> Result<(String, String), ParseError> {
1388 let idx = raw
1389 .find(" in ")
1390 .ok_or_else(|| ParseError::new("expected `binding in iterable` in @foreach(...)"))?;
1391 let binding = raw[..idx].trim().to_string();
1392 let iter = raw[idx + 4..].trim().to_string();
1393 if binding.is_empty() || iter.is_empty() {
1394 return Err(ParseError::new(
1395 "expected `binding in iterable` in @foreach(...)",
1396 ));
1397 }
1398 Ok((binding, iter))
1399}
1400
1401fn expect_closer(found: Option<Closer>, expected: &str) -> Result<(), ParseError> {
1402 match found {
1403 Some(Closer { tag, .. }) if tag == expected => Ok(()),
1404 other => Err(unexpected_closer(other, &format!("@{expected}"))),
1405 }
1406}
1407
1408fn unexpected_closer(found: Option<Closer>, expected: &str) -> ParseError {
1409 match found {
1410 Some(closer) => ParseError::new(format!(
1411 "expected {expected}, found {}",
1412 describe_closer(&closer)
1413 )),
1414 None => ParseError::new(format!("unexpected end of template, expected {expected}")),
1415 }
1416}
1417
1418fn describe_closer(closer: &Closer) -> String {
1422 match &closer.resource_tag_name {
1423 Some(name) => format!("</resource:{name}>"),
1424 None => format!("@{}", closer.tag),
1425 }
1426}
1427
1428fn skip_ws(cur: &mut Cursor) {
1429 let n = cur.rest().len() - cur.rest().trim_start().len();
1430 cur.advance(n);
1431}
1432
1433fn expect_char(cur: &mut Cursor, expected: char) -> Result<(), ParseError> {
1434 match cur.rest().chars().next() {
1435 Some(c) if c == expected => {
1436 cur.advance(c.len_utf8());
1437 Ok(())
1438 }
1439 Some(c) => Err(ParseError::new(format!(
1440 "expected '{expected}', found '{c}'"
1441 ))),
1442 None => Err(ParseError::new(format!(
1443 "expected '{expected}', found end of template"
1444 ))),
1445 }
1446}
1447
1448fn expect_one_of(cur: &mut Cursor, options: &[char]) -> Result<char, ParseError> {
1449 match cur.rest().chars().next() {
1450 Some(c) if options.contains(&c) => {
1451 cur.advance(c.len_utf8());
1452 Ok(c)
1453 }
1454 Some(c) => Err(ParseError::new(format!(
1455 "expected one of {options:?}, found '{c}'"
1456 ))),
1457 None => Err(ParseError::new(format!(
1458 "expected one of {options:?}, found end of template"
1459 ))),
1460 }
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465 use super::*;
1466
1467 #[test]
1468 fn parses_plain_text() {
1469 let nodes = parse("hello world").unwrap();
1470 assert_eq!(nodes, vec![Node::Text("hello world".to_string())]);
1471 }
1472
1473 #[test]
1474 fn parses_escaped_interpolation() {
1475 let nodes = parse("Hi {{ user.name }}!").unwrap();
1476 assert_eq!(
1477 nodes,
1478 vec![
1479 Node::Text("Hi ".to_string()),
1480 Node::Interpolate {
1481 expr: "user.name".to_string(),
1482 escape: true
1483 },
1484 Node::Text("!".to_string()),
1485 ]
1486 );
1487 }
1488
1489 #[test]
1490 fn parses_raw_interpolation() {
1491 let nodes = parse("{!! html !!}").unwrap();
1492 assert_eq!(
1493 nodes,
1494 vec![Node::Interpolate {
1495 expr: "html".to_string(),
1496 escape: false
1497 }]
1498 );
1499 }
1500
1501 #[test]
1502 fn a_comment_produces_no_node_at_all() {
1503 let nodes = parse("before {{-- a comment --}} after").unwrap();
1504 assert_eq!(
1505 nodes,
1506 vec![
1507 Node::Text("before ".to_string()),
1508 Node::Text(" after".to_string()),
1509 ]
1510 );
1511 }
1512
1513 #[test]
1514 fn a_directive_name_mentioned_inside_a_js_line_comment_in_a_script_block_is_plain_text() {
1515 let source = "<script>\n// see @push('head') and @wire for how this works\nconsole.log(1);\n</script>";
1520 let nodes = parse(source).unwrap();
1521 assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1522 }
1523
1524 #[test]
1525 fn a_directive_name_inside_a_js_block_comment_in_a_script_block_is_plain_text() {
1526 let source = "<script>/* uses @extends internally, not really */\nfoo();</script>";
1527 let nodes = parse(source).unwrap();
1528 assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1529 }
1530
1531 #[test]
1532 fn a_directive_name_inside_an_html_comment_is_plain_text_even_outside_a_script_block() {
1533 let source = "<!-- this page used to use @extends but doesn't anymore --><p>hi</p>";
1534 let nodes = parse(source).unwrap();
1535 assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1536 }
1537
1538 #[test]
1539 fn a_url_containing_double_slashes_inside_a_script_block_does_not_swallow_a_real_directive_after_it(
1540 ) {
1541 let source = r#"<script>var url = "http://example.com"; </script>@csrf"#;
1547 let nodes = parse(source).unwrap();
1548 assert_eq!(
1549 nodes,
1550 vec![
1551 Node::Text(r#"<script>var url = "http://example.com"; </script>"#.to_string()),
1552 Node::Csrf,
1553 ]
1554 );
1555 }
1556
1557 #[test]
1558 fn a_real_directive_still_works_normally_inside_a_script_block() {
1559 let nodes = parse("<script>@csrf</script>").unwrap();
1564 assert_eq!(
1565 nodes,
1566 vec![
1567 Node::Text("<script>".to_string()),
1568 Node::Csrf,
1569 Node::Text("</script>".to_string()),
1570 ]
1571 );
1572 }
1573
1574 #[test]
1575 fn a_double_slash_outside_any_script_block_is_never_treated_as_a_comment() {
1576 let nodes = parse("Visit https://example.com for more, then @csrf").unwrap();
1581 assert_eq!(
1582 nodes,
1583 vec![
1584 Node::Text("Visit https://example.com for more, then ".to_string()),
1585 Node::Csrf,
1586 ]
1587 );
1588 }
1589
1590 #[test]
1591 fn a_comment_containing_its_own_interpolation_and_directive_syntax_is_fully_inert() {
1592 let nodes = parse("{{-- @if(ok) {{ post.title }} @endif --}}{{ real }}").unwrap();
1599 assert_eq!(
1600 nodes,
1601 vec![Node::Interpolate {
1602 expr: "real".to_string(),
1603 escape: true
1604 }]
1605 );
1606 }
1607
1608 #[test]
1609 fn a_comment_can_contain_a_literal_dashes_close_brace_sequence_only_at_its_own_end() {
1610 let nodes = parse("{{-- one --}}{{-- two --}}").unwrap();
1615 assert_eq!(nodes, Vec::<Node>::new());
1616 }
1617
1618 #[test]
1619 fn an_unterminated_comment_is_a_clear_error() {
1620 let err = parse("{{-- never closed").unwrap_err();
1621 assert!(
1622 err.to_string().contains("unterminated"),
1623 "message was: {err}"
1624 );
1625 }
1626
1627 #[test]
1628 fn a_comment_takes_precedence_over_a_plain_double_brace_at_the_same_position() {
1629 let nodes = parse("{{-- x --}}").unwrap();
1634 assert_eq!(nodes, Vec::<Node>::new());
1635 }
1636
1637 #[test]
1638 fn literal_at_sign_is_not_a_directive() {
1639 let nodes = parse("contact: hello@example.com").unwrap();
1640 assert_eq!(
1641 nodes,
1642 vec![Node::Text("contact: hello@example.com".to_string())]
1643 );
1644 }
1645
1646 #[test]
1647 fn parses_if_else() {
1648 let nodes = parse("@if(user.admin) admin @else guest @endif").unwrap();
1649 assert_eq!(
1650 nodes,
1651 vec![Node::If {
1652 cond: "user.admin".to_string(),
1653 then_branch: vec![Node::Text(" admin ".to_string())],
1654 else_branch: vec![Node::Text(" guest ".to_string())],
1655 }]
1656 );
1657 }
1658
1659 #[test]
1660 fn parses_if_without_else() {
1661 let nodes = parse("@if(ok) yes @endif").unwrap();
1662 assert_eq!(
1663 nodes,
1664 vec![Node::If {
1665 cond: "ok".to_string(),
1666 then_branch: vec![Node::Text(" yes ".to_string())],
1667 else_branch: vec![],
1668 }]
1669 );
1670 }
1671
1672 #[test]
1673 fn parses_elseif_without_a_trailing_else() {
1674 let nodes = parse("@if(a) X @elseif(b) Y @endif").unwrap();
1678 assert_eq!(
1679 nodes,
1680 vec![Node::If {
1681 cond: "a".to_string(),
1682 then_branch: vec![Node::Text(" X ".to_string())],
1683 else_branch: vec![Node::If {
1684 cond: "b".to_string(),
1685 then_branch: vec![Node::Text(" Y ".to_string())],
1686 else_branch: vec![],
1687 }],
1688 }]
1689 );
1690 }
1691
1692 #[test]
1693 fn parses_elseif_with_a_trailing_else() {
1694 let nodes = parse("@if(a) X @elseif(b) Y @else Z @endif").unwrap();
1695 assert_eq!(
1696 nodes,
1697 vec![Node::If {
1698 cond: "a".to_string(),
1699 then_branch: vec![Node::Text(" X ".to_string())],
1700 else_branch: vec![Node::If {
1701 cond: "b".to_string(),
1702 then_branch: vec![Node::Text(" Y ".to_string())],
1703 else_branch: vec![Node::Text(" Z ".to_string())],
1704 }],
1705 }]
1706 );
1707 }
1708
1709 #[test]
1710 fn parses_a_chain_of_multiple_elseifs() {
1711 let nodes = parse("@if(a) X @elseif(b) Y @elseif(c) Z @else W @endif").unwrap();
1712 assert_eq!(
1713 nodes,
1714 vec![Node::If {
1715 cond: "a".to_string(),
1716 then_branch: vec![Node::Text(" X ".to_string())],
1717 else_branch: vec![Node::If {
1718 cond: "b".to_string(),
1719 then_branch: vec![Node::Text(" Y ".to_string())],
1720 else_branch: vec![Node::If {
1721 cond: "c".to_string(),
1722 then_branch: vec![Node::Text(" Z ".to_string())],
1723 else_branch: vec![Node::Text(" W ".to_string())],
1724 }],
1725 }],
1726 }]
1727 );
1728 }
1729
1730 #[test]
1731 fn parses_a_chain_of_multiple_elseifs_with_no_trailing_else() {
1732 let nodes = parse("@if(a) X @elseif(b) Y @elseif(c) Z @endif").unwrap();
1733 assert_eq!(
1734 nodes,
1735 vec![Node::If {
1736 cond: "a".to_string(),
1737 then_branch: vec![Node::Text(" X ".to_string())],
1738 else_branch: vec![Node::If {
1739 cond: "b".to_string(),
1740 then_branch: vec![Node::Text(" Y ".to_string())],
1741 else_branch: vec![Node::If {
1742 cond: "c".to_string(),
1743 then_branch: vec![Node::Text(" Z ".to_string())],
1744 else_branch: vec![],
1745 }],
1746 }],
1747 }]
1748 );
1749 }
1750
1751 #[test]
1752 fn stray_elseif_at_top_level_is_a_clear_error() {
1753 let err = parse("hi @elseif(x) there @endif").unwrap_err();
1754 assert!(err.to_string().contains("no matching opening directive"));
1755 }
1756
1757 #[test]
1758 fn if_condition_handles_nested_parens_and_strings() {
1759 let nodes = parse(r#"@if(user.name == "a)b") x @endif"#).unwrap();
1760 let Node::If { cond, .. } = &nodes[0] else {
1761 panic!("expected If node");
1762 };
1763 assert_eq!(cond, r#"user.name == "a)b""#);
1764 }
1765
1766 #[test]
1767 fn parses_foreach() {
1768 let nodes = parse("@foreach(post in posts){{ post }}@endforeach").unwrap();
1769 assert_eq!(
1770 nodes,
1771 vec![Node::Foreach {
1772 binding: "post".to_string(),
1773 iter: "posts".to_string(),
1774 body: vec![Node::Interpolate {
1775 expr: "post".to_string(),
1776 escape: true
1777 }],
1778 }]
1779 );
1780 }
1781
1782 #[test]
1783 fn parses_foreach_with_a_keyed_tuple_binding() {
1784 let nodes =
1785 parse("@foreach((key, item) in items.iter().enumerate()){{ key }}@endforeach").unwrap();
1786 assert_eq!(
1787 nodes,
1788 vec![Node::Foreach {
1789 binding: "(key, item)".to_string(),
1790 iter: "items.iter().enumerate()".to_string(),
1791 body: vec![Node::Interpolate {
1792 expr: "key".to_string(),
1793 escape: true
1794 }],
1795 }]
1796 );
1797 }
1798
1799 #[test]
1800 fn parses_extends_section_yield() {
1801 let nodes = parse("@extends('layouts.app')@section('content')hi@endsection").unwrap();
1802 assert_eq!(
1803 nodes,
1804 vec![
1805 Node::Extends("layouts.app".to_string()),
1806 Node::Section {
1807 name: "content".to_string(),
1808 body: vec![Node::Text("hi".to_string())],
1809 },
1810 ]
1811 );
1812
1813 let nodes = parse("@yield('content')").unwrap();
1814 assert_eq!(nodes, vec![Node::Yield("content".to_string())]);
1815 }
1816
1817 #[test]
1818 fn parses_push_and_stack() {
1819 let nodes = parse("@push('scripts')<script></script>@endpush").unwrap();
1820 assert_eq!(
1821 nodes,
1822 vec![Node::Push {
1823 name: "scripts".to_string(),
1824 body: vec![Node::Text("<script></script>".to_string())],
1825 }]
1826 );
1827
1828 let nodes = parse("@stack('scripts')").unwrap();
1829 assert_eq!(nodes, vec![Node::Stack("scripts".to_string())]);
1830 }
1831
1832 #[test]
1833 fn stray_endpush_without_a_matching_push_is_a_clear_error() {
1834 let err = parse("hi @endpush there").unwrap_err();
1835 assert!(err.to_string().contains("no matching opening directive"));
1836 }
1837
1838 #[test]
1839 fn parses_csrf() {
1840 let nodes = parse("<form>@csrf</form>").unwrap();
1841 assert_eq!(
1842 nodes,
1843 vec![
1844 Node::Text("<form>".to_string()),
1845 Node::Csrf,
1846 Node::Text("</form>".to_string()),
1847 ]
1848 );
1849 }
1850
1851 #[test]
1852 fn mismatched_closer_is_a_clear_error() {
1853 let err = parse("@if(x) hi @endforeach").unwrap_err();
1854 let message = err.to_string();
1855 assert!(message.contains("@endif"), "message was: {message}");
1856 assert!(
1857 message.contains("found @endforeach"),
1858 "message was: {message}"
1859 );
1860 }
1861
1862 #[test]
1863 fn unclosed_directive_is_a_clear_error() {
1864 let err = parse("@if(x) hi").unwrap_err();
1865 assert!(err.to_string().contains("unexpected end of template"));
1866 }
1867
1868 #[test]
1869 fn stray_closer_at_top_level_is_a_clear_error() {
1870 let err = parse("hi @endif").unwrap_err();
1871 assert!(err.to_string().contains("no matching opening directive"));
1872 }
1873
1874 #[test]
1875 fn parses_global_placeholder() {
1876 let nodes = parse("<title>@global(title)</title>").unwrap();
1877 assert_eq!(
1878 nodes,
1879 vec![
1880 Node::Text("<title>".to_string()),
1881 Node::Global {
1882 name: "title".to_string(),
1883 fallback: None,
1884 },
1885 Node::Text("</title>".to_string()),
1886 ]
1887 );
1888 }
1889
1890 #[test]
1891 fn parses_global_placeholder_with_a_fallback() {
1892 let nodes = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
1893 assert_eq!(
1894 nodes,
1895 vec![
1896 Node::Text("<title>".to_string()),
1897 Node::Global {
1898 name: "title".to_string(),
1899 fallback: Some("\"Larust\"".to_string()),
1900 },
1901 Node::Text("</title>".to_string()),
1902 ]
1903 );
1904 }
1905
1906 #[test]
1907 fn global_fallback_can_be_an_arbitrary_expression_with_nested_parens_and_strings() {
1908 let nodes = parse(r#"@global(title, default_title(page, "(fallback)"))"#).unwrap();
1909 assert_eq!(
1910 nodes,
1911 vec![Node::Global {
1912 name: "title".to_string(),
1913 fallback: Some(r#"default_title(page, "(fallback)")"#.to_string()),
1914 }]
1915 );
1916 }
1917
1918 #[test]
1919 fn missing_comma_or_close_paren_in_global_is_a_clear_error() {
1920 let err = parse("@global(title 'oops')").unwrap_err();
1921 assert!(
1922 err.to_string().contains("expected ',' or ')'"),
1923 "message was: {err}"
1924 );
1925 }
1926
1927 #[test]
1928 fn parses_globals_block_with_multiple_assignments() {
1929 let nodes = parse(
1930 "@globals\n\
1931 title = \"My Page\"\n\
1932 canonical = \"https://example.com\"\n\
1933 @endglobals",
1934 )
1935 .unwrap();
1936 assert_eq!(
1937 nodes,
1938 vec![Node::Globals(vec![
1939 GlobalEntry {
1940 name: "title".to_string(),
1941 expr: "\"My Page\"".to_string(),
1942 persist: false,
1943 },
1944 GlobalEntry {
1945 name: "canonical".to_string(),
1946 expr: "\"https://example.com\"".to_string(),
1947 persist: false,
1948 },
1949 ])]
1950 );
1951 }
1952
1953 #[test]
1954 fn globals_block_skips_blank_lines() {
1955 let nodes = parse("@globals\n\ntitle = \"x\"\n\n@endglobals").unwrap();
1956 assert_eq!(
1957 nodes,
1958 vec![Node::Globals(vec![GlobalEntry {
1959 name: "title".to_string(),
1960 expr: "\"x\"".to_string(),
1961 persist: false,
1962 }])]
1963 );
1964 }
1965
1966 #[test]
1967 fn globals_assignment_with_equality_operator_in_expression() {
1968 let nodes = parse("@globals\nactive = state == \"open\"\n@endglobals").unwrap();
1969 assert_eq!(
1970 nodes,
1971 vec![Node::Globals(vec![GlobalEntry {
1972 name: "active".to_string(),
1973 expr: "state == \"open\"".to_string(),
1974 persist: false,
1975 }])]
1976 );
1977 }
1978
1979 #[test]
1980 fn globals_assignment_with_equals_sign_inside_string_literal() {
1981 let nodes = parse("@globals\nnote = \"a=b\"\n@endglobals").unwrap();
1982 assert_eq!(
1983 nodes,
1984 vec![Node::Globals(vec![GlobalEntry {
1985 name: "note".to_string(),
1986 expr: "\"a=b\"".to_string(),
1987 persist: false,
1988 }])]
1989 );
1990 }
1991
1992 #[test]
1993 fn parses_a_persist_prefixed_globals_entry() {
1994 let nodes = parse("@globals\npersist theme = \"dark\"\n@endglobals").unwrap();
1995 assert_eq!(
1996 nodes,
1997 vec![Node::Globals(vec![GlobalEntry {
1998 name: "theme".to_string(),
1999 expr: "\"dark\"".to_string(),
2000 persist: true,
2001 }])]
2002 );
2003 }
2004
2005 #[test]
2006 fn persist_and_plain_entries_can_be_mixed_in_the_same_block() {
2007 let nodes = parse(
2008 "@globals\n\
2009 menuvisible = \"false\"\n\
2010 persist theme = \"dark\"\n\
2011 @endglobals",
2012 )
2013 .unwrap();
2014 assert_eq!(
2015 nodes,
2016 vec![Node::Globals(vec![
2017 GlobalEntry {
2018 name: "menuvisible".to_string(),
2019 expr: "\"false\"".to_string(),
2020 persist: false,
2021 },
2022 GlobalEntry {
2023 name: "theme".to_string(),
2024 expr: "\"dark\"".to_string(),
2025 persist: true,
2026 },
2027 ])]
2028 );
2029 }
2030
2031 #[test]
2032 fn persist_entrys_name_is_still_validated() {
2033 let err = parse("@globals\npersist not valid = \"x\"\n@endglobals").unwrap_err();
2034 assert!(
2035 err.to_string().contains("invalid variable name"),
2036 "message was: {err}"
2037 );
2038 }
2039
2040 #[test]
2041 fn malformed_globals_line_is_a_clear_error() {
2042 let err = parse("@globals\nnot an assignment\n@endglobals").unwrap_err();
2043 assert!(
2044 err.to_string().contains("expected `name = expression`"),
2045 "message was: {err}"
2046 );
2047 }
2048
2049 #[test]
2050 fn unterminated_globals_block_is_a_clear_error() {
2051 let err = parse("@globals\ntitle = \"x\"").unwrap_err();
2052 assert!(err.to_string().contains("unterminated @globals block"));
2053 }
2054
2055 #[test]
2056 fn globals_assignment_with_an_invalid_name_is_a_clear_error() {
2057 let err = parse("@globals\nmy title = \"x\"\n@endglobals").unwrap_err();
2063 assert!(
2064 err.to_string().contains("invalid variable name"),
2065 "message was: {err}"
2066 );
2067 }
2068
2069 #[test]
2070 fn stray_endglobals_at_top_level_is_a_clear_error() {
2071 let err = parse("hi @endglobals there").unwrap_err();
2072 assert!(err.to_string().contains("no matching opening directive"));
2073 }
2074
2075 #[test]
2076 fn parses_wire_with_no_props() {
2077 let nodes = parse("@wire('search-box')").unwrap();
2078 assert_eq!(
2079 nodes,
2080 vec![Node::Wire {
2081 name: "search-box".to_string(),
2082 props: vec![],
2083 }]
2084 );
2085 }
2086
2087 #[test]
2088 fn parses_wire_with_props() {
2089 let nodes = parse(r#"@wire('search-box', { query: "", limit: 10 })"#).unwrap();
2090 assert_eq!(
2091 nodes,
2092 vec![Node::Wire {
2093 name: "search-box".to_string(),
2094 props: vec![
2095 ("query".to_string(), "\"\"".to_string()),
2096 ("limit".to_string(), "10".to_string()),
2097 ],
2098 }]
2099 );
2100 }
2101
2102 #[test]
2103 fn wire_props_handle_nested_parens_braces_brackets_and_strings() {
2104 let nodes = parse(
2105 r#"@wire('widget', { items: vec![1, 2], meta: Foo { a: 1 }, label: default_label("(x)") })"#,
2106 )
2107 .unwrap();
2108 assert_eq!(
2109 nodes,
2110 vec![Node::Wire {
2111 name: "widget".to_string(),
2112 props: vec![
2113 ("items".to_string(), "vec![1, 2]".to_string()),
2114 ("meta".to_string(), "Foo { a: 1 }".to_string()),
2115 ("label".to_string(), r#"default_label("(x)")"#.to_string()),
2116 ],
2117 }]
2118 );
2119 }
2120
2121 #[test]
2122 fn wire_props_handle_a_trailing_comma() {
2123 let nodes = parse(r#"@wire('widget', { query: "x", })"#).unwrap();
2124 assert_eq!(
2125 nodes,
2126 vec![Node::Wire {
2127 name: "widget".to_string(),
2128 props: vec![("query".to_string(), "\"x\"".to_string())],
2129 }]
2130 );
2131 }
2132
2133 #[test]
2134 fn wire_with_empty_props_object() {
2135 let nodes = parse("@wire('widget', {})").unwrap();
2136 assert_eq!(
2137 nodes,
2138 vec![Node::Wire {
2139 name: "widget".to_string(),
2140 props: vec![],
2141 }]
2142 );
2143 }
2144
2145 #[test]
2146 fn missing_prop_colon_is_a_clear_error() {
2147 let err = parse("@wire('widget', { query })").unwrap_err();
2148 assert!(
2149 err.to_string().contains("expected `name: expression`"),
2150 "message was: {err}"
2151 );
2152 }
2153
2154 #[test]
2155 fn missing_prop_value_is_a_clear_error() {
2156 let err = parse("@wire('widget', { query: })").unwrap_err();
2157 assert!(
2158 err.to_string().contains("expected an expression after ':'"),
2159 "message was: {err}"
2160 );
2161 }
2162
2163 #[test]
2164 fn invalid_prop_name_is_a_clear_error() {
2165 let err = parse(r#"@wire('widget', { "query": "x" })"#).unwrap_err();
2166 assert!(
2167 err.to_string().contains("invalid prop name"),
2168 "message was: {err}"
2169 );
2170 }
2171
2172 #[test]
2173 fn unterminated_wire_props_is_a_clear_error() {
2174 let err = parse("@wire('widget', { query: \"x\"").unwrap_err();
2175 assert!(
2176 err.to_string().contains("unterminated '{' in @wire"),
2177 "message was: {err}"
2178 );
2179 }
2180
2181 #[test]
2182 fn missing_comma_or_close_paren_in_wire_is_a_clear_error() {
2183 let err = parse("@wire('widget' oops)").unwrap_err();
2184 assert!(
2185 err.to_string().contains("expected ',' or ')'"),
2186 "message was: {err}"
2187 );
2188 }
2189
2190 #[test]
2191 fn parses_larustscripts() {
2192 let nodes = parse("<body>@larustscripts</body>").unwrap();
2193 assert_eq!(
2194 nodes,
2195 vec![
2196 Node::Text("<body>".to_string()),
2197 Node::LarustScripts,
2198 Node::Text("</body>".to_string()),
2199 ]
2200 );
2201 }
2202
2203 #[test]
2204 fn parses_loadonce() {
2205 let nodes = parse("@loadonce<script></script>@endloadonce").unwrap();
2206 assert_eq!(
2207 nodes,
2208 vec![Node::LoadOnce(vec![Node::Text(
2209 "<script></script>".to_string()
2210 )])]
2211 );
2212 }
2213
2214 #[test]
2215 fn loadonce_can_contain_ordinary_directives() {
2216 let nodes = parse("@loadonce@if(ok)yes@endif@endloadonce").unwrap();
2217 assert_eq!(
2218 nodes,
2219 vec![Node::LoadOnce(vec![Node::If {
2220 cond: "ok".to_string(),
2221 then_branch: vec![Node::Text("yes".to_string())],
2222 else_branch: vec![],
2223 }])]
2224 );
2225 }
2226
2227 #[test]
2228 fn unclosed_loadonce_is_a_clear_error() {
2229 let err = parse("@loadonce<script></script>").unwrap_err();
2230 assert!(err.to_string().contains("unexpected end of template"));
2231 }
2232
2233 #[test]
2234 fn stray_endloadonce_at_top_level_is_a_clear_error() {
2235 let err = parse("hi @endloadonce there").unwrap_err();
2236 assert!(err.to_string().contains("no matching opening directive"));
2237 }
2238
2239 #[test]
2240 fn parses_resource_with_no_props_and_a_slot() {
2241 let nodes = parse("@resource('components.panel')<p>hi</p>@endresource").unwrap();
2242 assert_eq!(
2243 nodes,
2244 vec![Node::Resource {
2245 name: "components.panel".to_string(),
2246 props: vec![],
2247 slot: vec![Node::Text("<p>hi</p>".to_string())],
2248 }]
2249 );
2250 }
2251
2252 #[test]
2253 fn parses_resource_with_props_and_an_empty_slot() {
2254 let nodes =
2255 parse(r#"@resource('components.badge', { label: "New" })@endresource"#).unwrap();
2256 assert_eq!(
2257 nodes,
2258 vec![Node::Resource {
2259 name: "components.badge".to_string(),
2260 props: vec![("label".to_string(), "\"New\"".to_string())],
2261 slot: vec![],
2262 }]
2263 );
2264 }
2265
2266 #[test]
2267 fn resource_slot_can_contain_ordinary_directives() {
2268 let nodes = parse("@resource('components.panel')@if(ok)yes@endif@endresource").unwrap();
2269 assert_eq!(
2270 nodes,
2271 vec![Node::Resource {
2272 name: "components.panel".to_string(),
2273 props: vec![],
2274 slot: vec![Node::If {
2275 cond: "ok".to_string(),
2276 then_branch: vec![Node::Text("yes".to_string())],
2277 else_branch: vec![],
2278 }],
2279 }]
2280 );
2281 }
2282
2283 #[test]
2284 fn unclosed_resource_is_a_clear_error() {
2285 let err = parse("@resource('components.panel')<p>hi</p>").unwrap_err();
2286 assert!(err.to_string().contains("unexpected end of template"));
2287 }
2288
2289 #[test]
2290 fn stray_endresource_at_top_level_is_a_clear_error() {
2291 let err = parse("hi @endresource there").unwrap_err();
2292 assert!(err.to_string().contains("no matching opening directive"));
2293 }
2294
2295 #[test]
2296 fn missing_comma_or_close_paren_in_resource_is_a_clear_error() {
2297 let err = parse("@resource('panel' oops)@endresource").unwrap_err();
2298 assert!(
2299 err.to_string().contains("expected ',' or ')'"),
2300 "message was: {err}"
2301 );
2302 }
2303
2304 #[test]
2305 fn parses_live_with_a_string_literal_channel() {
2306 let nodes = parse("@live('posts.count')<span>5</span>@endlive").unwrap();
2307 assert_eq!(
2308 nodes,
2309 vec![Node::Live {
2310 channel: "'posts.count'".to_string(),
2311 body: vec![Node::Text("<span>5</span>".to_string())],
2312 }]
2313 );
2314 }
2315
2316 #[test]
2317 fn live_channel_can_be_an_arbitrary_expression() {
2318 let nodes = parse(r#"@live(format!("post.{}.comments", post.id))hi@endlive"#).unwrap();
2323 assert_eq!(
2324 nodes,
2325 vec![Node::Live {
2326 channel: r#"format!("post.{}.comments", post.id)"#.to_string(),
2327 body: vec![Node::Text("hi".to_string())],
2328 }]
2329 );
2330 }
2331
2332 #[test]
2333 fn live_body_can_contain_ordinary_directives() {
2334 let nodes = parse("@live('c')@if(ok)yes@endif@endlive").unwrap();
2335 assert_eq!(
2336 nodes,
2337 vec![Node::Live {
2338 channel: "'c'".to_string(),
2339 body: vec![Node::If {
2340 cond: "ok".to_string(),
2341 then_branch: vec![Node::Text("yes".to_string())],
2342 else_branch: vec![],
2343 }],
2344 }]
2345 );
2346 }
2347
2348 #[test]
2349 fn unclosed_live_is_a_clear_error() {
2350 let err = parse("@live('c')<p>hi</p>").unwrap_err();
2351 assert!(err.to_string().contains("unexpected end of template"));
2352 }
2353
2354 #[test]
2355 fn stray_endlive_at_top_level_is_a_clear_error() {
2356 let err = parse("hi @endlive there").unwrap_err();
2357 assert!(err.to_string().contains("no matching opening directive"));
2358 }
2359
2360 #[test]
2361 fn unterminated_paren_in_live_channel_is_a_clear_error() {
2362 let err = parse("@live('c'").unwrap_err();
2363 assert!(err.to_string().contains("unterminated '(' in directive"));
2364 }
2365
2366 #[test]
2367 fn parses_can_with_no_else() {
2368 let nodes = parse("@can(Permission::EditPosts)<b>yes</b>@endcan").unwrap();
2369 assert_eq!(
2370 nodes,
2371 vec![Node::Can {
2372 permission: "Permission::EditPosts".to_string(),
2373 then_branch: vec![Node::Text("<b>yes</b>".to_string())],
2374 else_branch: vec![],
2375 }]
2376 );
2377 }
2378
2379 #[test]
2380 fn parses_can_with_an_else_branch() {
2381 let nodes = parse("@can(Permission::EditPosts)yes@else no@endcan").unwrap();
2382 assert_eq!(
2383 nodes,
2384 vec![Node::Can {
2385 permission: "Permission::EditPosts".to_string(),
2386 then_branch: vec![Node::Text("yes".to_string())],
2387 else_branch: vec![Node::Text(" no".to_string())],
2388 }]
2389 );
2390 }
2391
2392 #[test]
2393 fn parses_role_with_no_else() {
2394 let nodes = parse("@role(Role::Admin)<b>admin</b>@endrole").unwrap();
2395 assert_eq!(
2396 nodes,
2397 vec![Node::Role {
2398 role: "Role::Admin".to_string(),
2399 then_branch: vec![Node::Text("<b>admin</b>".to_string())],
2400 else_branch: vec![],
2401 }]
2402 );
2403 }
2404
2405 #[test]
2406 fn parses_role_with_an_else_branch() {
2407 let nodes = parse("@role(Role::Admin)yes@else no@endrole").unwrap();
2408 assert_eq!(
2409 nodes,
2410 vec![Node::Role {
2411 role: "Role::Admin".to_string(),
2412 then_branch: vec![Node::Text("yes".to_string())],
2413 else_branch: vec![Node::Text(" no".to_string())],
2414 }]
2415 );
2416 }
2417
2418 #[test]
2419 fn can_body_can_contain_ordinary_directives() {
2420 let nodes = parse("@can(Permission::EditPosts)@if(ok)yes@endif@endcan").unwrap();
2421 assert_eq!(
2422 nodes,
2423 vec![Node::Can {
2424 permission: "Permission::EditPosts".to_string(),
2425 then_branch: vec![Node::If {
2426 cond: "ok".to_string(),
2427 then_branch: vec![Node::Text("yes".to_string())],
2428 else_branch: vec![],
2429 }],
2430 else_branch: vec![],
2431 }]
2432 );
2433 }
2434
2435 #[test]
2436 fn can_can_nest_inside_role() {
2437 let nodes =
2438 parse("@role(Role::Admin)@can(Permission::EditPosts)yes@endcan@endrole").unwrap();
2439 assert_eq!(
2440 nodes,
2441 vec![Node::Role {
2442 role: "Role::Admin".to_string(),
2443 then_branch: vec![Node::Can {
2444 permission: "Permission::EditPosts".to_string(),
2445 then_branch: vec![Node::Text("yes".to_string())],
2446 else_branch: vec![],
2447 }],
2448 else_branch: vec![],
2449 }]
2450 );
2451 }
2452
2453 #[test]
2454 fn unclosed_can_is_a_clear_error() {
2455 let err = parse("@can(Permission::EditPosts)<p>hi</p>").unwrap_err();
2456 assert!(err.to_string().contains("unexpected end of template"));
2457 }
2458
2459 #[test]
2460 fn mismatched_can_endrole_is_a_clear_error() {
2461 let err = parse("@can(Permission::EditPosts)hi@endrole").unwrap_err();
2462 assert!(
2463 err.to_string().contains("expected @else or @endcan"),
2464 "message was: {err}"
2465 );
2466 }
2467
2468 #[test]
2469 fn stray_endcan_at_top_level_is_a_clear_error() {
2470 let err = parse("hi @endcan there").unwrap_err();
2471 assert!(err.to_string().contains("no matching opening directive"));
2472 }
2473
2474 #[test]
2475 fn stray_endrole_at_top_level_is_a_clear_error() {
2476 let err = parse("hi @endrole there").unwrap_err();
2477 assert!(err.to_string().contains("no matching opening directive"));
2478 }
2479
2480 #[test]
2481 fn parses_spa_with_a_body() {
2482 let nodes = parse("@spa hi @endspa").unwrap();
2483 assert_eq!(nodes, vec![Node::Spa(vec![Node::Text(" hi ".to_string())])]);
2484 }
2485
2486 #[test]
2487 fn spa_can_wrap_a_yield() {
2488 let nodes = parse("@spa @yield('content') @endspa").unwrap();
2489 assert_eq!(
2490 nodes,
2491 vec![Node::Spa(vec![
2492 Node::Text(" ".to_string()),
2493 Node::Yield("content".to_string()),
2494 Node::Text(" ".to_string()),
2495 ])]
2496 );
2497 }
2498
2499 #[test]
2500 fn unclosed_spa_is_a_clear_error() {
2501 let err = parse("@spa hi").unwrap_err();
2502 assert!(err.to_string().contains("unexpected end of template"));
2503 }
2504
2505 #[test]
2506 fn mismatched_spa_closer_is_a_clear_error() {
2507 let err = parse("@spa hi @endforeach").unwrap_err();
2508 assert!(
2509 err.to_string().contains("expected @endspa"),
2510 "message was: {err}"
2511 );
2512 }
2513
2514 #[test]
2515 fn stray_endspa_at_top_level_is_a_clear_error() {
2516 let err = parse("hi @endspa there").unwrap_err();
2517 assert!(err.to_string().contains("no matching opening directive"));
2518 }
2519
2520 #[test]
2521 fn resource_tag_syntax_produces_the_same_node_the_directive_syntax_does() {
2522 let tag = parse(r#"<resource:components.badge label="New" />"#).unwrap();
2523 let directive =
2524 parse(r#"@resource('components.badge', { label: "New" })@endresource"#).unwrap();
2525 assert_eq!(tag, directive);
2526 }
2527
2528 #[test]
2529 fn parses_resource_tag_self_closing_with_no_props() {
2530 let nodes = parse("<resource:components.panel/>").unwrap();
2531 assert_eq!(
2532 nodes,
2533 vec![Node::Resource {
2534 name: "components.panel".to_string(),
2535 props: vec![],
2536 slot: vec![],
2537 }]
2538 );
2539 }
2540
2541 #[test]
2542 fn parses_resource_tag_self_closing_with_literal_and_dynamic_props() {
2543 let nodes = parse(r#"<resource:components.badge label="New" :count="unread" />"#).unwrap();
2544 assert_eq!(
2545 nodes,
2546 vec![Node::Resource {
2547 name: "components.badge".to_string(),
2548 props: vec![
2549 ("label".to_string(), "\"New\"".to_string()),
2550 ("count".to_string(), "unread".to_string()),
2551 ],
2552 slot: vec![],
2553 }]
2554 );
2555 }
2556
2557 #[test]
2558 fn parses_resource_tag_block_form_with_a_slot() {
2559 let nodes =
2560 parse(r#"<resource:components.panel title="Hi"><p>hi</p></resource:components.panel>"#)
2561 .unwrap();
2562 assert_eq!(
2563 nodes,
2564 vec![Node::Resource {
2565 name: "components.panel".to_string(),
2566 props: vec![("title".to_string(), "\"Hi\"".to_string())],
2567 slot: vec![Node::Text("<p>hi</p>".to_string())],
2568 }]
2569 );
2570 }
2571
2572 #[test]
2573 fn resource_tag_literal_attribute_with_a_double_quote_is_escaped() {
2574 let nodes = parse(r#"<resource:components.badge label='Say "hi"' />"#).unwrap();
2575 assert_eq!(
2576 nodes,
2577 vec![Node::Resource {
2578 name: "components.badge".to_string(),
2579 props: vec![("label".to_string(), "\"Say \\\"hi\\\"\"".to_string())],
2580 slot: vec![],
2581 }]
2582 );
2583 }
2584
2585 #[test]
2586 fn resource_tag_slot_can_contain_ordinary_directives() {
2587 let nodes =
2588 parse("<resource:components.panel>@if(ok)yes@endif</resource:components.panel>")
2589 .unwrap();
2590 assert_eq!(
2591 nodes,
2592 vec![Node::Resource {
2593 name: "components.panel".to_string(),
2594 props: vec![],
2595 slot: vec![Node::If {
2596 cond: "ok".to_string(),
2597 then_branch: vec![Node::Text("yes".to_string())],
2598 else_branch: vec![],
2599 }],
2600 }]
2601 );
2602 }
2603
2604 #[test]
2605 fn nested_resource_tags_parse_correctly() {
2606 let nodes = parse("<resource:a><resource:b>x</resource:b></resource:a>").unwrap();
2607 assert_eq!(
2608 nodes,
2609 vec![Node::Resource {
2610 name: "a".to_string(),
2611 props: vec![],
2612 slot: vec![Node::Resource {
2613 name: "b".to_string(),
2614 props: vec![],
2615 slot: vec![Node::Text("x".to_string())],
2616 }],
2617 }]
2618 );
2619 }
2620
2621 #[test]
2622 fn resource_tag_name_mismatch_between_open_and_close_is_a_clear_error() {
2623 let err = parse("<resource:a>hi</resource:b>").unwrap_err();
2624 let message = err.to_string();
2625 assert!(
2626 message.contains("expected </resource:a>"),
2627 "message was: {message}"
2628 );
2629 assert!(
2630 message.contains("found </resource:b>"),
2631 "message was: {message}"
2632 );
2633 }
2634
2635 #[test]
2636 fn unclosed_resource_tag_is_a_clear_error() {
2637 let err = parse("<resource:a><p>hi</p>").unwrap_err();
2638 assert!(err.to_string().contains("unexpected end of template"));
2639 }
2640
2641 #[test]
2642 fn stray_closing_resource_tag_at_top_level_is_a_clear_error() {
2643 let err = parse("hi </resource:a> there").unwrap_err();
2644 let message = err.to_string();
2645 assert!(
2646 message.contains("no matching <resource:a>"),
2647 "message was: {message}"
2648 );
2649 }
2650
2651 #[test]
2652 fn resource_tag_with_no_name_is_a_clear_error() {
2653 let err = parse("<resource:/>").unwrap_err();
2654 assert!(
2655 err.to_string()
2656 .contains("expected a name after '<resource:'"),
2657 "message was: {err}"
2658 );
2659 }
2660
2661 #[test]
2662 fn wire_tag_syntax_produces_the_same_node_the_directive_syntax_does() {
2663 let tag = parse(r#"<wire:search-box query="" :limit="10" />"#).unwrap();
2664 let directive = parse(r#"@wire('search-box', { query: "", limit: 10 })"#).unwrap();
2665 assert_eq!(tag, directive);
2666 }
2667
2668 #[test]
2669 fn parses_wire_tag_with_no_props() {
2670 let nodes = parse("<wire:search-box/>").unwrap();
2671 assert_eq!(
2672 nodes,
2673 vec![Node::Wire {
2674 name: "search-box".to_string(),
2675 props: vec![],
2676 }]
2677 );
2678 }
2679
2680 #[test]
2681 fn parses_wire_tag_with_literal_and_dynamic_props() {
2682 let nodes = parse(r#"<wire:post-form title="Untitled" :post_id="post.id" />"#).unwrap();
2683 assert_eq!(
2684 nodes,
2685 vec![Node::Wire {
2686 name: "post-form".to_string(),
2687 props: vec![
2688 ("title".to_string(), "\"Untitled\"".to_string()),
2689 ("post_id".to_string(), "post.id".to_string()),
2690 ],
2691 }]
2692 );
2693 }
2694
2695 #[test]
2696 fn wire_tag_without_self_close_is_a_clear_error() {
2697 let err = parse("<wire:search-box>").unwrap_err();
2698 assert!(
2699 err.to_string().contains("must be self-closing"),
2700 "message was: {err}"
2701 );
2702 }
2703
2704 #[test]
2705 fn wire_tag_with_no_name_is_a_clear_error() {
2706 let err = parse("<wire:/>").unwrap_err();
2707 assert!(
2708 err.to_string().contains("expected a name after '<wire:'"),
2709 "message was: {err}"
2710 );
2711 }
2712
2713 #[test]
2714 fn wire_tag_unterminated_is_a_clear_error() {
2715 let err = parse("<wire:search-box").unwrap_err();
2716 assert!(
2717 err.to_string()
2718 .contains("expected '/>' to close <wire:search-box>"),
2719 "message was: {err}"
2720 );
2721 }
2722
2723 #[test]
2724 fn parses_trusted_rust_code_block() {
2725 assert_eq!(
2726 parse("@code let label = \"Hello\"; @endcode{{ label }}").unwrap(),
2727 vec![
2728 Node::Code(" let label = \"Hello\"; ".to_string()),
2729 Node::Interpolate {
2730 expr: "label".to_string(),
2731 escape: true,
2732 },
2733 ]
2734 );
2735 }
2736
2737 #[test]
2738 fn parses_a_code_block_immediately_followed_by_a_raw_interpolation() {
2739 assert_eq!(
2744 parse(
2745 "@code let __vitex_tags = larust_support::vitex::tags(&[\"a\"]); @endcode{!! __vitex_tags !!}"
2746 )
2747 .unwrap(),
2748 vec![
2749 Node::Code(
2750 " let __vitex_tags = larust_support::vitex::tags(&[\"a\"]); ".to_string()
2751 ),
2752 Node::Interpolate {
2753 expr: "__vitex_tags".to_string(),
2754 escape: false,
2755 },
2756 ]
2757 );
2758 }
2759
2760 #[test]
2761 fn parses_vitex_with_multiple_entries() {
2762 assert_eq!(
2766 parse("@vitex(['resources/css/app.min.css', 'resources/js/app.min.js'])").unwrap(),
2767 vec![Node::Vitex(vec![
2768 "resources/css/app.min.css".to_string(),
2769 "resources/js/app.min.js".to_string(),
2770 ])]
2771 );
2772 }
2773
2774 #[test]
2775 fn parses_js_with_a_simple_expression() {
2776 let nodes = parse("<script>const post = @js(post);</script>").unwrap();
2777 assert_eq!(
2778 nodes,
2779 vec![
2780 Node::Text("<script>const post = ".to_string()),
2781 Node::Js("post".to_string()),
2782 Node::Text(";</script>".to_string()),
2783 ]
2784 );
2785 }
2786
2787 #[test]
2788 fn js_expression_can_be_arbitrary_like_live_channel() {
2789 let nodes = parse(r#"@js(serde_json::json!({"id": post.id}))"#).unwrap();
2793 assert_eq!(
2794 nodes,
2795 vec![Node::Js(
2796 r#"serde_json::json!({"id": post.id})"#.to_string()
2797 )]
2798 );
2799 }
2800
2801 #[test]
2802 fn unterminated_paren_in_js_expression_is_a_clear_error() {
2803 let err = parse("@js(post").unwrap_err();
2804 assert!(err.to_string().contains("unterminated '(' in directive"));
2805 }
2806
2807 #[test]
2808 fn parses_vitex_with_a_single_double_quoted_entry() {
2809 assert_eq!(
2810 parse("@vitex([\"resources/js/app.js\"])").unwrap(),
2811 vec![Node::Vitex(vec!["resources/js/app.js".to_string()])]
2812 );
2813 }
2814
2815 #[test]
2816 fn parses_vitex_tolerating_a_trailing_comma() {
2817 assert_eq!(
2818 parse("@vitex(['a', 'b',])").unwrap(),
2819 vec![Node::Vitex(vec!["a".to_string(), "b".to_string()])]
2820 );
2821 }
2822
2823 #[test]
2824 fn rejects_vitex_with_an_empty_array() {
2825 let err = parse("@vitex([])").unwrap_err();
2826 assert!(
2827 err.to_string().contains("at least one entry"),
2828 "message was: {err}"
2829 );
2830 }
2831
2832 #[test]
2833 fn rejects_vitex_missing_the_array_brackets() {
2834 let err = parse("@vitex('resources/js/app.js')").unwrap_err();
2835 assert!(err.to_string().contains('['), "message was: {err}");
2836 }
2837}