Skip to main content

larust_view/
parser.rs

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
42/// Directives that end whatever block is currently being parsed (an
43/// `@if`/`@foreach`/`@section` body). Matched against regardless of what
44/// the caller "expects" - the caller checks the returned tag itself and
45/// produces a clear "expected @endif, found @endforeach" error on mismatch,
46/// rather than the parser silently overrunning into the wrong block.
47const 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
64/// A directive that ended the block currently being parsed. `elseif_cond`
65/// is only ever set when `tag == "elseif"` - its condition is consumed
66/// right where the closer itself is recognized (see `read_closer`), since
67/// `@elseif`'s condition logically belongs to whatever nested `@if` it
68/// desugars into (see `parse_if_tail`), not to this closer signal.
69/// `tag` borrows from `KEYWORDS` (always `'static`), not the source text -
70/// no allocation for something as cheap and frequent as a block boundary.
71///
72/// `resource_tag_name` is only ever set when `tag == "endresourcetag"` - a
73/// closing `</resource:name>` tag (see `parse_resource_tag`), the one
74/// closer whose "which specific thing does this close" identity can't be
75/// captured by `tag` alone, since (unlike every `@endXxx` directive) two
76/// sibling `<resource:a>`/`<resource:b>` tags need their closers told
77/// apart by name, not just by kind.
78struct 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    /// Whether the cursor's current position sits inside a `<script>`
106    /// element's own content - updated by [`Self::update_script_state`]
107    /// as each text chunk is swept up into a `Node::Text`. Read by
108    /// [`find_next_at_directive`] to decide whether `//`/`/* */` should
109    /// also be treated as comment-span starts (see that function's own
110    /// doc comment) - a `//` in plain HTML prose (a URL, most commonly)
111    /// must never be treated as a comment, so this only ever applies
112    /// inside an actual `<script>` element.
113    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    /// Updates `in_script` for whatever comes *after* `consumed_text` - a
134    /// text chunk `parse_nodes` just swept into a `Node::Text` (or is
135    /// about to). See [`script_state_after`] for the actual logic - this
136    /// is just that function applied to `self.in_script` as the baseline,
137    /// stored back onto the cursor for every *later* scan to inherit.
138    fn update_script_state(&mut self, consumed_text: &str) {
139        self.in_script = script_state_after(consumed_text, self.in_script);
140    }
141}
142
143/// The `in_script` state in effect *after* `text` - `baseline` if `text`
144/// crosses no `<script`/`</script>` boundary at all (entirely inside, or
145/// entirely outside, an already-open element), otherwise whatever the
146/// *last* boundary `text` crosses leaves it as (`<script>` elements never
147/// nest, so only the last one matters - any earlier pair in the same
148/// `text` is already a closed, self-contained span).
149///
150/// Used two ways: [`Cursor::update_script_state`] applies this to a text
151/// chunk that's already been fully consumed, to seed every *later* scan's
152/// own baseline. [`find_next_at_directive`] applies it *per candidate `@`
153/// position*, to `s[..pos]` rather than a whole consumed chunk - necessary
154/// because the `<script>` tag a candidate sits after is often still
155/// *unconsumed* at scan time (nothing has swept it into a `Node::Text`
156/// yet - that only happens once the marker search this function feeds
157/// into has already picked a stopping point), so relying solely on the
158/// cursor's own already-stored `in_script` would be scanning with stale,
159/// pre-`<script>` state for the very first marker search that needs to
160/// know better. Confirmed as a real bug during testing, not just reasoned
161/// out: without this, `@push`/`@wire` immediately after a `<script>` tag
162/// on the very first scan (nothing consumed yet at all) were still being
163/// read as live directives, since the cursor's own `in_script` hadn't had
164/// a chance to update yet.
165fn 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
176/// The byte offset of the *last* occurrence of `tag` in `s` that's an
177/// actual tag boundary - immediately followed by whitespace, `>`, `/`, or
178/// end of string, not just any substring match (`<script` inside
179/// `<scripture>` would otherwise be a false positive - unlikely in a real
180/// template, but cheap to guard against correctly).
181fn 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    /// `{{-- ... --}}` - Blade's own comment syntax, and `.blade.xr`'s
201    /// too, for exactly the reason Laravel developers reach for it:
202    /// commenting out a span of real template content (which may itself
203    /// contain `{{ }}`/`{!! !!}`/`@directive` syntax) without deleting it.
204    /// Consumed atomically here - its entire span is scanned and skipped
205    /// in one step (see [`parse_comment`]) *before* the normal
206    /// `{{`/`{!!`/`@`-scanning in this loop ever looks inside it, so
207    /// nothing nested inside a comment is ever mistaken for a real,
208    /// active directive. `xr convert` relies on exactly this: a Laravel
209    /// `{{-- ... --}}` comment carries straight through into the
210    /// generated `.blade.xr` output as this same syntax, verbatim,
211    /// instead of being silently dropped - see `larust_convert::blade::
212    /// scan`'s own `Marker::BladeComment` handling.
213    Comment,
214    At(&'static str),
215    ResourceTagOpen,
216    ResourceTagClose,
217    WireTagOpen,
218    /// `<Image ...>` - see [`parse_image_tag`]'s own doc comment. Reserved
219    /// capitalized tag name, deliberately: real HTML has no native
220    /// `<Image>` element (SVG's own `<image>` is lowercase), so this can
221    /// never collide with genuine markup the way reserving a lowercase
222    /// name could.
223    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    // Checked before the plain `{{` (`Escaped`) marker below: `{{--` is a
229    // strictly more specific prefix of `{{`, and both would report the
230    // same starting offset for a real comment, so `Comment` must appear
231    // first in the array below for `min_by_key`'s "first element wins a
232    // tie" rule to prefer it over the generic `{{` interpretation.
233    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    // Checked in this order (close before open) purely so the two `find`
237    // calls are independent of each other's result - `</resource:` and
238    // `<resource:` are distinct literal substrings (`</` vs `<r`), so
239    // there's no actual ambiguity between them either way.
240    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
264/// Finds the next genuine `<Image ...>` tag open - unlike `<wire:`/
265/// `<resource:`, `<Image` has no trailing delimiter character of its own
266/// to disambiguate it from some *other*, differently-named tag that
267/// merely starts with the same six characters (`<ImageGallery>`, say), so
268/// a plain `s.find("<Image")` isn't enough on its own. Same boundary-check
269/// idiom [`find_next_at_directive`] already uses for `@keyword` - only a
270/// match immediately followed by whitespace, `/`, or `>` counts; anything
271/// else (an identifier character continuing the name) is skipped past and
272/// the search resumes, exactly as an unrecognized `@word` falls through
273/// to being treated as plain text instead of a directive.
274fn 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
291/// Only treats `@` as a directive marker when immediately followed by a
292/// recognized keyword at a word boundary - otherwise a literal `@` in HTML
293/// content (an email address, an `@media` CSS-like string, etc.) would
294/// wrongly be parsed as a directive. Also skips a would-be match that sits
295/// inside a comment (an HTML `<!-- ... -->`, always; a JS `// ...`/`/* ...
296/// */`, only when the candidate is inside a `<script>` element - see
297/// [`is_inside_a_comment`]'s own doc comment) - real bug, not hypothetical:
298/// an explanatory comment mentioning a directive by name (`// see
299/// @push('head') for how this works`, inside a `<script>` block) used to
300/// be parsed exactly like a live directive, breaking the whole template.
301///
302/// `baseline_in_script` is the cursor's own `in_script` as of the *start*
303/// of `s`, not necessarily what's true at any given candidate `@` further
304/// in - `s` can itself contain an unconsumed `<script>` opening tag before
305/// a candidate, which [`script_state_after`] (applied to `s[..pos]` for
306/// each one) accounts for. See that function's own doc comment for why
307/// this two-level check is necessary, not just a stored flag.
308fn 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
333/// Whether byte offset `pos` in `s` sits inside a comment span the
334/// directive scanner needs to treat as inert - an HTML comment (`<!--
335/// ... -->`), checked everywhere, or (only when `in_script` - i.e. `pos`
336/// is inside a `<script>` element's own content) a JS line comment (`//`
337/// to end of line) or block comment (`/* ... */`). String-literal-aware
338/// for the JS case specifically - a `//`/`/*` inside a `'...'`/`"..."`/
339/// `` `...` `` string (a URL, most commonly - `"https://example.com"`)
340/// must never be mistaken for a real comment start. Deliberately *not*
341/// regex-literal-aware (`/pattern/`) - genuinely ambiguous to tell apart
342/// from division without real JS parsing (`a / b`  vs. `/re/`), a known,
343/// accepted limitation rather than a guess at disambiguating it.
344///
345/// Scans forward from the start of `s` to `pos` with a small state
346/// machine - simple and, for a compile-time macro over template source
347/// that's never more than a few KB, fast enough not to matter; this is
348/// not attempting to be a real JS tokenizer beyond comments and strings.
349fn 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; // nothing left to find - `pos` is in plain text
389                };
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, // unterminated - pos is inside it
399            },
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                        // A quote immediately preceded by a backslash is
419                        // escaped - a pragmatic approximation (doesn't
420                        // count a *preceding* escaped backslash, e.g.
421                        // `\\'`), not a fully rigorous JS-escape lexer.
422                        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
443/// Builds the `Closer` signal for a just-consumed closing directive.
444/// `@elseif`'s condition is consumed here, not by whatever handles the
445/// closer - it belongs to the nested `@if` it desugars into (see
446/// `parse_if_tail`), which doesn't exist yet at this point in parsing.
447fn 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                    // Consumed and discarded - a comment produces no
484                    // `Node` at all, matching Blade's own real `{{-- ...
485                    // --}}` semantics (zero output, not a visible HTML
486                    // comment). Reusing `parse_braces` here is exactly
487                    // right, not just convenient: it already does the
488                    // "advance past the open delimiter, find the *first*
489                    // matching close, advance past that" scan this needs,
490                    // with no nesting awareness - real Blade comments
491                    // aren't nestable either (the first `--}}` always
492                    // closes them), so anything inside, including its own
493                    // `{{ }}`/`{!! !!}`/`@directive` text, is skipped over
494                    // as one atomic span rather than fed back through this
495                    // same scanning loop.
496                    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()); // consume "@" + keyword
535
536                        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
663/// Reads raw Rust statements until `@endcode`. Unlike normal template
664/// bodies this is intentionally not parsed as markup: the macro validates
665/// the statements with `syn` when it expands the template.
666fn 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
676/// Parses everything after an `@if(...)`/`@elseif(...)` condition, through
677/// whichever of `@elseif`/`@else`/`@endif` ends it - recursively
678/// desugaring any `@elseif` chain into nested `Node::If`s in the else
679/// branch. `@if(a) X @elseif(b) Y @else Z @endif` builds exactly the same
680/// tree hand-nesting `@if(a) X @else @if(b) Y @else Z @endif @endif`
681/// would, so `larust-macros`' codegen needs no changes at all to support
682/// `@elseif` - it only ever sees plain, possibly-nested `Node::If`s.
683fn 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
709/// `@can`/`@role`'s own `parse_if_tail` - everything after the `(...)`
710/// argument, through whichever of `@else`/`@end{end_tag}` ends it. No
711/// `@elseif`-equivalent chain: unlike `@if`, `@can`/`@role` only ever test
712/// one permission/role name, so there's nothing for an "else-if" to chain
713/// against - a plain `@else` is the only alternate branch either supports.
714/// Kept separate from `parse_if_tail` (rather than generalizing that
715/// function with an `end_tag` parameter) so `@if`'s own `@elseif`-desugaring
716/// logic stays untouched and easy to reason about on its own.
717fn 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
754/// Scans a `'...'`- or `"..."`-quoted string starting at the current
755/// position (the opening quote itself not yet consumed) - shared by
756/// `parse_quoted_arg` (`@extends('...')`, `@yield('...')`, ...) and
757/// `parse_wire_args` (`@wire('name', ...)`'s own name argument, which needs
758/// just the quoted-string scan without `parse_quoted_arg`'s surrounding
759/// `(`/`)` handling, since more may follow after the name).
760fn 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); // consume through the closing quote
769    Ok(value)
770}
771
772/// Parses `@vitex(['path1', 'path2', ...])` - an array of quoted entry
773/// paths, matching Laravel's own `@vite([...])` syntax exactly (so a
774/// hand-converted template reads the same way the original did). Each
775/// entry is a plain quoted string via [`parse_quoted_string`] - no
776/// interpolation, no expression, same as every real `@vite(...)` call
777/// this exists to mirror. A trailing comma before `]` is tolerated
778/// (`['a', 'b',]`), matching how the array literal usually reads when
779/// hand-formatted across multiple lines.
780fn 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
814/// Parses `@global(name)` or `@global(name, fallback)`. `name` is a bare
815/// identifier, not a quoted string - deliberately different from
816/// `parse_quoted_arg`: it matches the bare-identifier style the `@globals`
817/// block itself uses (`title = "..."`), so the same literal token names the
818/// placeholder and its setter with no quote-mark mismatch between the two.
819/// `fallback`, when present, is any Rust expression up to the matching
820/// closing paren (via `scan_to_matching_close_paren`, shared with
821/// `parse_paren_expr`) - same free-form-expression convention as a
822/// `@globals` assignment's own right-hand side.
823fn 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
864/// Parses an `@globals ... @endglobals` block body: one `name = expr`
865/// assignment per non-blank line. Deliberately **not** routed through
866/// `parse_nodes` - this is a different grammar (assignment lines), not
867/// blade markup - so it scans for the literal `"@endglobals"` marker the
868/// same way `parse_quoted_arg` scans for a closing quote, rather than
869/// tokenizing through the normal directive dispatch.
870fn 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
881/// A `persist ` line prefix (a literal keyword, not a directive of its
882/// own) - see `GlobalEntry::persist`'s own doc comment. Checked *before*
883/// `find_assignment_split`, since `persist` never appears as part of a
884/// name/expression this line's own grammar would otherwise need to
885/// disambiguate from.
886const 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        // Must match the exact character class `parse_global_args` accepts
907        // for `@global(name)`'s own placeholder name - otherwise a typo'd
908        // or malformed name here (a stray space, a hyphen, ...) would
909        // silently register under a key no `@global(...)` placeholder
910        // could ever match, producing a dead map entry with no error at
911        // all: the placeholder would just quietly fall through to its
912        // `fallback` (or empty) instead.
913        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
933/// Parses `@wire('name')` or `@wire('name', { prop: expr, ... })`. The
934/// component name reuses `parse_quoted_string`, the same convention
935/// `@extends('...')` uses; the optional props object is a brace-delimited,
936/// comma-separated `key: expr` list - see `parse_prop_entries`.
937fn parse_wire_args(cur: &mut Cursor) -> Result<(String, Vec<(String, String)>), ParseError> {
938    parse_name_and_props_args(cur, "wire")
939}
940
941/// Parses `@resource('name')` or `@resource('name', { prop: expr, ... })` -
942/// the directive's opening arguments only; the `... @endresource` body
943/// (the slot) is parsed separately by the caller via the ordinary
944/// `parse_nodes` block-parsing path, same as `@section`/`@push`/`@loadonce`.
945/// Identical grammar to `@wire(...)`'s own arguments - shares the same
946/// scanner rather than duplicating it.
947fn parse_resource_args(cur: &mut Cursor) -> Result<(String, Vec<(String, String)>), ParseError> {
948    parse_name_and_props_args(cur, "resource")
949}
950
951/// Shared by `@wire(...)`/`@resource(...)`: `('name')` or
952/// `('name', { prop: expr, ... })`, with `directive` only used to name the
953/// directive in error messages.
954fn 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
987/// Parses `<resource:name attr="literal" :attr2="expr" />` (self-closing,
988/// empty slot) or `<resource:name ...>slot</resource:name>` (block form) -
989/// an alternate, HTML-tag-flavored surface syntax for exactly the same
990/// [`Node::Resource`] the `@resource('name', { ... }) ... @endresource`
991/// directive (`parse_resource_args` above) produces. Not a distinct AST
992/// concept: `resolve.rs` and `larust-macros`' codegen can't tell the two
993/// spellings apart, so a template can freely mix both, and adding this
994/// second spelling required zero changes outside this file.
995///
996/// Unprefixed attributes are literal string props - `title="Your profile."`
997/// becomes exactly the prop this file already builds for `{ title: "Your
998/// profile." }` (the raw attribute text re-escaped into a Rust string
999/// literal by `literal_attr_to_rust_string`). A leading `:` marks an
1000/// attribute's value as a raw Rust expression instead - `:count="count"` is
1001/// `{ count: count }` - Blade's own `<x-alert :message="$message">`
1002/// convention. The cursor is positioned just past `<resource:` on entry.
1003fn 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        // A closing tag with the *wrong* name (a copy-paste/rename slip)
1026        // is deliberately distinguished from "no closing tag at all" here
1027        // - both fall into this arm, but `unexpected_closer` renders each
1028        // found-value distinctly, so the error names exactly what was
1029        // actually found (a mismatched tag, an unrelated `@endXxx`
1030        // bubbling up from an unbalanced directive inside the slot, or
1031        // end of template).
1032        other => Err(unexpected_closer(other, &format!("</resource:{name}>"))),
1033    }
1034}
1035
1036/// Parses `<wire:name attr="literal" :attr2="expr" />` - the HTML-tag-
1037/// flavored counterpart to `@wire('name', { ... })`, producing the
1038/// identical [`Node::Wire`]. **Always self-closing** - unlike
1039/// `<resource:...>`, `@wire(...)` has never had a body/slot concept at
1040/// all (a mounted component renders entirely from its own template), so
1041/// there's no block form to support, and a stray non-self-closing `>` is
1042/// a clear error rather than being silently accepted as "no slot". The
1043/// cursor is positioned just past `<wire:` on entry.
1044fn 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
1069/// `<Image src="..." width="..." height="..." alt="..." quality="..." />` -
1070/// Larust's answer to Next.js's `<Image />` component (see `larust-image`'s
1071/// own doc comment for the full design: on-demand resize + AVIF/JPEG
1072/// re-encoding, cached after the first request). Unlike `<wire:name>`/
1073/// `<resource:name>`, there's no dotted name segment to scan first - every
1074/// piece of information this tag needs is an ordinary attribute, parsed by
1075/// the same [`parse_tag_attrs`] every other tag form shares. Required
1076/// attrs (`src`/`width`/`height`), optional ones (`alt`/`quality`), and
1077/// coercing a bare numeric literal attribute (`width="400"`, parsed by
1078/// [`parse_tag_attrs`] as the Rust string `"400"`) back into a real `u32`
1079/// are all `larust-macros`' own codegen concern, not this parser's -
1080/// keeping this function itself exactly as thin as [`parse_wire_tag`].
1081/// Always self-closing, same reasoning as `<wire:...>`'s own doc comment:
1082/// an image tag renders nothing but itself, there's no slot concept to
1083/// support. The cursor is positioned just past `<Image` on entry.
1084fn 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
1107/// Scans a tag's dotted name (`components.panel` after `<resource:`, or a
1108/// component name after `<wire:`) - any run of non-whitespace characters
1109/// up to `/`, `>`, or whitespace. Deliberately no character-class
1110/// validation, same "an invalid name surfaces later, not here" stance the
1111/// directive-syntax name arguments already take. `tag_prefix`
1112/// (`"resource"`/`"wire"`) is only used to phrase the error message.
1113fn 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
1128/// Parses zero or more `name="literal"` / `:name="expr"` attributes,
1129/// stopping (without consuming) at the tag's own closing `/>` or `>` -
1130/// shared by `<resource:...>` and `<wire:...>`, whose attribute grammar is
1131/// identical; `tag_prefix` is only used to phrase an error message.
1132fn 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
1174/// Wraps raw, unescaped attribute text in a valid Rust string literal -
1175/// `Your profile.` becomes `"Your profile."`, `Say "hi"` becomes `"Say
1176/// \"hi\""`. Only `"` and `\` need escaping; Rust string literals otherwise
1177/// accept any character (including a literal newline) as-is.
1178fn 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
1192/// Parses the interior of a `@wire(..., { key: expr, key2: expr2 })` props
1193/// object - cursor positioned just past the opening `{`. Scans to the
1194/// matching `}`, tracking one *combined* nesting depth over `(`/`{`/`[`
1195/// (any of which may legitimately appear inside a prop's own expression,
1196/// e.g. `{ items: vec![1, 2] }`) rather than three independent counters -
1197/// sufficient here because this scan only needs to know when it's back at
1198/// the outer object's own closing brace, not whether the source's brackets
1199/// are individually well-matched (a genuine mismatch surfaces later as a
1200/// `syn::parse_str` error in `larust-macros` instead). Quoted spans are
1201/// skipped with the same backslash-escape handling as
1202/// `scan_to_matching_close_paren`. Advances the cursor past the matching
1203/// closing `}`.
1204fn 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
1243/// Splits a `@wire(...)` props body on top-level commas (honoring nested
1244/// brackets and quoted spans, same depth-tracking technique as
1245/// `parse_prop_entries`) and each resulting entry on its first colon (via
1246/// `find_prop_colon`). A trailing comma, or an entirely empty `{}`, leaves
1247/// a blank final segment - tolerated, not an error, matching
1248/// `parse_globals_entries`'s own "blank lines are skipped" convention.
1249fn 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    // Same character class `@global`/`@globals` already require of a
1306    // placeholder name - kept consistent rather than inventing a separate
1307    // rule for prop names.
1308    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
1323/// Finds the byte offset of the first unquoted `:` in a prop entry. Unlike
1324/// `find_assignment_split`, no operator-adjacency check (for `!=`/`<=`/...)
1325/// or bracket-depth tracking is needed: a prop key is required to be a bare
1326/// identifier immediately before this colon, and identifiers can't contain
1327/// `:` or brackets, so the first unquoted `:` encountered while scanning
1328/// left-to-right is always the key/value separator - never a `::` path
1329/// separator or a nested struct-literal field's own colon, both of which
1330/// can only occur *after* it, inside `expr`.
1331fn 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
1358/// The same character class `parse_global_args` accepts for `@global(name)` -
1359/// alphanumeric plus underscore, non-empty. Shared so a `@globals` block's
1360/// assignment names can never silently drift out of sync with what a
1361/// `@global(...)` placeholder is able to match.
1362fn is_valid_global_name(name: &str) -> bool {
1363    !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
1364}
1365
1366/// Finds the byte offset of the assignment `=` in a `name = expr` line -
1367/// not just the first `=`, since `expr` may itself contain comparison
1368/// operators (`==`, `!=`, `<=`, `>=`) or a string literal containing `=`.
1369/// Skips over quoted spans (with the same backslash-escape handling as
1370/// `parse_paren_expr`) and rejects an `=` that's adjacent to `!`/`<`/`>`/`=`
1371/// (part of a multi-char operator), returning the first truly isolated `=`.
1372fn 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
1408/// Extracts the balanced, string-literal-aware contents of `@if(...)` /
1409/// `@foreach(...)` - an expression may itself contain parens
1410/// (`user.age > (18 + 1)`) or a string literal containing a paren
1411/// (`name == "(test)"`), both of which must not prematurely close the
1412/// directive's own outer parens.
1413fn 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
1419/// Scans from the current position - already *past* an opening `(` - to its
1420/// matching `)`, honoring nested parens and string literals containing
1421/// parens (`user.age > (18 + 1)`, `name == "(test)"`), and advances the
1422/// cursor past that closing paren. Shared by `parse_paren_expr` (for
1423/// `@if(...)`/`@foreach(...)`) and `parse_global_args` (for `@global(name,
1424/// fallback)`'s optional second argument).
1425fn 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
1494/// Renders a `Closer` the way it should read in an error message - a
1495/// resource-tag closer as `</resource:name>`, every other closer as
1496/// `@tag`, matching how each was actually spelled in the template.
1497fn 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        // The exact real-world shape that broke a real template: an
1592        // explanatory `//` comment inside a `<script>` block mentioning a
1593        // directive by name, previously parsed exactly like a live
1594        // `@push`/`@wire` directive instead of staying inert prose.
1595        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        // A `//` inside a string literal (a URL, most commonly) must never
1618        // be mistaken for the start of a JS comment - if it were, `@csrf`
1619        // right after it on the same line would incorrectly be treated as
1620        // still "inside a comment" and never recognized as the real
1621        // directive it is.
1622        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        // The fix must not accidentally suppress *real* directive usage
1636        // inside a `<script>` tag - template-value injection into embedded
1637        // JS (`const x = {{ value }};`) is a genuine, supported pattern,
1638        // and this proves an `@`-directive works there too.
1639        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        // `in_script` gates JS-comment detection specifically so a URL in
1653        // plain HTML prose (never inside a `<script>` tag) is completely
1654        // unaffected by this fix - `//` there was never a directive
1655        // marker to begin with, and still isn't.
1656        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        // The whole reason this needs to be its own atomic scan rather
1669        // than plain dead text: a naive HTML-comment-shaped passthrough
1670        // would leave `{{ post.title }}`/`@if(...)`/`@endif` sitting in
1671        // the output as *live* syntax for this exact parser to re-scan
1672        // and misinterpret later. Consuming the comment's whole span in
1673        // one step means none of this is ever seen as real directives.
1674        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        // Blade comments aren't nestable - the *first* `--}}` always
1687        // closes one, matching real Blade semantics exactly (and this
1688        // parser's own pre-existing `Marker::BladeComment` handling in
1689        // `larust-convert`, which this mirrors).
1690        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        // `{{--` and `{{` both match at the same starting offset for a
1706        // real comment - `next_marker` must prefer the more specific
1707        // `Comment` interpretation, not misparse it as an `Interpolate`
1708        // whose "expression" text starts with two stray dashes.
1709        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        // Desugars into a nested `Node::If` in the else branch - exactly
1751        // what hand-nesting `@if(a) X @else @if(b) Y @endif @endif` would
1752        // produce, proving `@elseif` needs no codegen changes.
1753        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        // A name containing a space (or any character `@global(name)`'s own
2134        // bare-identifier scanner wouldn't accept) must be rejected here
2135        // too - otherwise it silently registers under a key no
2136        // `@global(...)` placeholder could ever match, and the mismatch
2137        // would surface nowhere at all.
2138        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        // Unlike `@wire`/`@resource`'s own `name` argument (a quoted
2395        // string only), `@live`'s channel is parsed the same way
2396        // `@if`/`@foreach` parse their own arguments - any expression, so
2397        // a channel can be scoped dynamically per-resource.
2398        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        // A `@code` block immediately followed - no whitespace - by a
2816        // raw (unescaped) interpolation splicing its own result in,
2817        // rather than the escaped `{{ }}` form `parses_trusted_rust_code_
2818        // block` above already covers.
2819        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        // Real source: `components/layouts/app.blade.xr`'s translated
2839        // `@vite(['resources/css/app.min.css', 'resources/js/app.min.js'])`
2840        // - `@vitex` mirrors that exact array-of-paths syntax.
2841        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        // `@js(...)` takes any expression, the same way `@live(...)`'s
2866        // channel argument does (`parse_paren_expr`, not the quoted-string-
2867        // only argument `@wire`/`@resource` take).
2868        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}