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}
219
220fn next_marker(s: &str, in_script: bool) -> Option<(usize, MarkerKind)> {
221    let at = find_next_at_directive(s, in_script).map(|(p, kw)| (p, MarkerKind::At(kw)));
222    // Checked before the plain `{{` (`Escaped`) marker below: `{{--` is a
223    // strictly more specific prefix of `{{`, and both would report the
224    // same starting offset for a real comment, so `Comment` must appear
225    // first in the array below for `min_by_key`'s "first element wins a
226    // tie" rule to prefer it over the generic `{{` interpretation.
227    let comment = s.find("{{--").map(|p| (p, MarkerKind::Comment));
228    let raw = s.find("{!!").map(|p| (p, MarkerKind::Raw));
229    let esc = s.find("{{").map(|p| (p, MarkerKind::Escaped));
230    // Checked in this order (close before open) purely so the two `find`
231    // calls are independent of each other's result - `</resource:` and
232    // `<resource:` are distinct literal substrings (`</` vs `<r`), so
233    // there's no actual ambiguity between them either way.
234    let resource_close = s
235        .find("</resource:")
236        .map(|p| (p, MarkerKind::ResourceTagClose));
237    let resource_open = s
238        .find("<resource:")
239        .map(|p| (p, MarkerKind::ResourceTagOpen));
240    let wire_open = s.find("<wire:").map(|p| (p, MarkerKind::WireTagOpen));
241
242    [
243        at,
244        comment,
245        raw,
246        esc,
247        resource_close,
248        resource_open,
249        wire_open,
250    ]
251    .into_iter()
252    .flatten()
253    .min_by_key(|(p, _)| *p)
254}
255
256/// Only treats `@` as a directive marker when immediately followed by a
257/// recognized keyword at a word boundary - otherwise a literal `@` in HTML
258/// content (an email address, an `@media` CSS-like string, etc.) would
259/// wrongly be parsed as a directive. Also skips a would-be match that sits
260/// inside a comment (an HTML `<!-- ... -->`, always; a JS `// ...`/`/* ...
261/// */`, only when the candidate is inside a `<script>` element - see
262/// [`is_inside_a_comment`]'s own doc comment) - real bug, not hypothetical:
263/// an explanatory comment mentioning a directive by name (`// see
264/// @push('head') for how this works`, inside a `<script>` block) used to
265/// be parsed exactly like a live directive, breaking the whole template.
266///
267/// `baseline_in_script` is the cursor's own `in_script` as of the *start*
268/// of `s`, not necessarily what's true at any given candidate `@` further
269/// in - `s` can itself contain an unconsumed `<script>` opening tag before
270/// a candidate, which [`script_state_after`] (applied to `s[..pos]` for
271/// each one) accounts for. See that function's own doc comment for why
272/// this two-level check is necessary, not just a stored flag.
273fn find_next_at_directive(s: &str, baseline_in_script: bool) -> Option<(usize, &'static str)> {
274    let mut search_from = 0;
275    while let Some(rel) = s[search_from..].find('@') {
276        let pos = search_from + rel;
277        let in_script = script_state_after(&s[..pos], baseline_in_script);
278        if is_inside_a_comment(s, pos, in_script) {
279            search_from = pos + 1;
280            continue;
281        }
282        let after = &s[pos + 1..];
283        let hit = KEYWORDS.iter().find(|kw| {
284            after.starts_with(**kw)
285                && after[kw.len()..]
286                    .chars()
287                    .next()
288                    .is_none_or(|c| !c.is_alphanumeric() && c != '_')
289        });
290        if let Some(kw) = hit {
291            return Some((pos, kw));
292        }
293        search_from = pos + 1;
294    }
295    None
296}
297
298/// Whether byte offset `pos` in `s` sits inside a comment span the
299/// directive scanner needs to treat as inert - an HTML comment (`<!--
300/// ... -->`), checked everywhere, or (only when `in_script` - i.e. `pos`
301/// is inside a `<script>` element's own content) a JS line comment (`//`
302/// to end of line) or block comment (`/* ... */`). String-literal-aware
303/// for the JS case specifically - a `//`/`/*` inside a `'...'`/`"..."`/
304/// `` `...` `` string (a URL, most commonly - `"https://example.com"`)
305/// must never be mistaken for a real comment start. Deliberately *not*
306/// regex-literal-aware (`/pattern/`) - genuinely ambiguous to tell apart
307/// from division without real JS parsing (`a / b`  vs. `/re/`), a known,
308/// accepted limitation rather than a guess at disambiguating it.
309///
310/// Scans forward from the start of `s` to `pos` with a small state
311/// machine - simple and, for a compile-time macro over template source
312/// that's never more than a few KB, fast enough not to matter; this is
313/// not attempting to be a real JS tokenizer beyond comments and strings.
314fn is_inside_a_comment(s: &str, pos: usize, in_script: bool) -> bool {
315    #[derive(Clone, Copy, PartialEq)]
316    enum State {
317        Normal,
318        HtmlComment,
319        LineComment,
320        BlockComment,
321        JsString(char),
322    }
323
324    let mut rest = &s[..pos];
325    let mut state = State::Normal;
326
327    while !rest.is_empty() {
328        match state {
329            State::Normal => {
330                let html_comment = rest.find("<!--").map(|p| (p, State::HtmlComment, 4));
331                let line_comment = in_script
332                    .then(|| rest.find("//"))
333                    .flatten()
334                    .map(|p| (p, State::LineComment, 2));
335                let block_comment = in_script
336                    .then(|| rest.find("/*"))
337                    .flatten()
338                    .map(|p| (p, State::BlockComment, 2));
339                let string_start = in_script
340                    .then(|| rest.find(['\'', '"', '`']))
341                    .flatten()
342                    .map(|p| {
343                        let quote = rest[p..].chars().next().expect("just found at this offset");
344                        (p, State::JsString(quote), 1)
345                    });
346
347                let Some((found_at, new_state, skip)) =
348                    [html_comment, line_comment, block_comment, string_start]
349                        .into_iter()
350                        .flatten()
351                        .min_by_key(|(p, _, _)| *p)
352                else {
353                    break; // nothing left to find - `pos` is in plain text
354                };
355                rest = &rest[found_at + skip..];
356                state = new_state;
357            }
358            State::HtmlComment => match rest.find("-->") {
359                Some(p) => {
360                    rest = &rest[p + 3..];
361                    state = State::Normal;
362                }
363                None => return true, // unterminated - pos is inside it
364            },
365            State::LineComment => match rest.find('\n') {
366                Some(p) => {
367                    rest = &rest[p + 1..];
368                    state = State::Normal;
369                }
370                None => return true,
371            },
372            State::BlockComment => match rest.find("*/") {
373                Some(p) => {
374                    rest = &rest[p + 2..];
375                    state = State::Normal;
376                }
377                None => return true,
378            },
379            State::JsString(quote) => {
380                let mut search = rest;
381                loop {
382                    match search.find(quote) {
383                        // A quote immediately preceded by a backslash is
384                        // escaped - a pragmatic approximation (doesn't
385                        // count a *preceding* escaped backslash, e.g.
386                        // `\\'`), not a fully rigorous JS-escape lexer.
387                        Some(p) if p > 0 && search.as_bytes()[p - 1] == b'\\' => {
388                            search = &search[p + 1..];
389                        }
390                        Some(p) => {
391                            rest = &search[p + 1..];
392                            state = State::Normal;
393                            break;
394                        }
395                        None => return true,
396                    }
397                }
398            }
399        }
400    }
401
402    matches!(
403        state,
404        State::HtmlComment | State::LineComment | State::BlockComment
405    )
406}
407
408/// Builds the `Closer` signal for a just-consumed closing directive.
409/// `@elseif`'s condition is consumed here, not by whatever handles the
410/// closer - it belongs to the nested `@if` it desugars into (see
411/// `parse_if_tail`), which doesn't exist yet at this point in parsing.
412fn read_closer(cur: &mut Cursor, kw: &'static str) -> Result<Closer, ParseError> {
413    let elseif_cond = if kw == "elseif" {
414        Some(parse_paren_expr(cur)?)
415    } else {
416        None
417    };
418    Ok(Closer {
419        tag: kw,
420        elseif_cond,
421        resource_tag_name: None,
422    })
423}
424
425fn parse_nodes(cur: &mut Cursor) -> Result<(Vec<Node>, Option<Closer>), ParseError> {
426    let mut nodes = Vec::new();
427
428    loop {
429        match next_marker(cur.rest(), cur.in_script) {
430            None => {
431                let text = cur.rest();
432                if !text.is_empty() {
433                    cur.update_script_state(text);
434                    nodes.push(Node::Text(text.to_string()));
435                }
436                cur.advance(text.len());
437                return Ok((nodes, None));
438            }
439            Some((offset, kind)) => {
440                if offset > 0 {
441                    let text = &cur.rest()[..offset];
442                    cur.update_script_state(text);
443                    nodes.push(Node::Text(text.to_string()));
444                }
445                cur.advance(offset);
446
447                match kind {
448                    // Consumed and discarded - a comment produces no
449                    // `Node` at all, matching Blade's own real `{{-- ...
450                    // --}}` semantics (zero output, not a visible HTML
451                    // comment). Reusing `parse_braces` here is exactly
452                    // right, not just convenient: it already does the
453                    // "advance past the open delimiter, find the *first*
454                    // matching close, advance past that" scan this needs,
455                    // with no nesting awareness - real Blade comments
456                    // aren't nestable either (the first `--}}` always
457                    // closes them), so anything inside, including its own
458                    // `{{ }}`/`{!! !!}`/`@directive` text, is skipped over
459                    // as one atomic span rather than fed back through this
460                    // same scanning loop.
461                    MarkerKind::Comment => {
462                        parse_braces(cur, "{{--", "--}}")?;
463                    }
464                    MarkerKind::Escaped => {
465                        let expr = parse_braces(cur, "{{", "}}")?;
466                        nodes.push(Node::Interpolate { expr, escape: true });
467                    }
468                    MarkerKind::Raw => {
469                        let expr = parse_braces(cur, "{!!", "!!}")?;
470                        nodes.push(Node::Interpolate {
471                            expr,
472                            escape: false,
473                        });
474                    }
475                    MarkerKind::ResourceTagOpen => {
476                        nodes.push(parse_resource_tag(cur)?);
477                    }
478                    MarkerKind::WireTagOpen => {
479                        nodes.push(parse_wire_tag(cur)?);
480                    }
481                    MarkerKind::ResourceTagClose => {
482                        cur.advance("</resource:".len());
483                        let name = scan_tag_name(cur, "resource")?;
484                        skip_ws(cur);
485                        expect_char(cur, '>')?;
486                        return Ok((
487                            nodes,
488                            Some(Closer {
489                                tag: "endresourcetag",
490                                elseif_cond: None,
491                                resource_tag_name: Some(name),
492                            }),
493                        ));
494                    }
495                    MarkerKind::At(kw) => {
496                        cur.advance(1 + kw.len()); // consume "@" + keyword
497
498                        if CLOSERS.contains(&kw) {
499                            return Ok((nodes, Some(read_closer(cur, kw)?)));
500                        }
501
502                        match kw {
503                            "extends" => {
504                                let name = parse_quoted_arg(cur)?;
505                                nodes.push(Node::Extends(name));
506                            }
507                            "yield" => {
508                                let name = parse_quoted_arg(cur)?;
509                                nodes.push(Node::Yield(name));
510                            }
511                            "section" => {
512                                let name = parse_quoted_arg(cur)?;
513                                let (body, closer) = parse_nodes(cur)?;
514                                expect_closer(closer, "endsection")?;
515                                nodes.push(Node::Section { name, body });
516                            }
517                            "if" => {
518                                let cond = parse_paren_expr(cur)?;
519                                let (then_branch, else_branch) = parse_if_tail(cur)?;
520                                nodes.push(Node::If {
521                                    cond,
522                                    then_branch,
523                                    else_branch,
524                                });
525                            }
526                            "foreach" => {
527                                let raw = parse_paren_expr(cur)?;
528                                let (binding, iter) = split_foreach(&raw)?;
529                                let (body, closer) = parse_nodes(cur)?;
530                                expect_closer(closer, "endforeach")?;
531                                nodes.push(Node::Foreach {
532                                    binding,
533                                    iter,
534                                    body,
535                                });
536                            }
537                            "push" => {
538                                let name = parse_quoted_arg(cur)?;
539                                let (body, closer) = parse_nodes(cur)?;
540                                expect_closer(closer, "endpush")?;
541                                nodes.push(Node::Push { name, body });
542                            }
543                            "stack" => {
544                                let name = parse_quoted_arg(cur)?;
545                                nodes.push(Node::Stack(name));
546                            }
547                            "csrf" => {
548                                nodes.push(Node::Csrf);
549                            }
550                            "global" => {
551                                let (name, fallback) = parse_global_args(cur)?;
552                                nodes.push(Node::Global { name, fallback });
553                            }
554                            "globals" => {
555                                let entries = parse_globals_block(cur)?;
556                                nodes.push(Node::Globals(entries));
557                            }
558                            "wire" => {
559                                let (name, props) = parse_wire_args(cur)?;
560                                nodes.push(Node::Wire { name, props });
561                            }
562                            "larustscripts" => {
563                                nodes.push(Node::LarustScripts);
564                            }
565                            "loadonce" => {
566                                let (body, closer) = parse_nodes(cur)?;
567                                expect_closer(closer, "endloadonce")?;
568                                nodes.push(Node::LoadOnce(body));
569                            }
570                            "resource" => {
571                                let (name, props) = parse_resource_args(cur)?;
572                                let (slot, closer) = parse_nodes(cur)?;
573                                expect_closer(closer, "endresource")?;
574                                nodes.push(Node::Resource { name, props, slot });
575                            }
576                            "live" => {
577                                let channel = parse_paren_expr(cur)?;
578                                let (body, closer) = parse_nodes(cur)?;
579                                expect_closer(closer, "endlive")?;
580                                nodes.push(Node::Live { channel, body });
581                            }
582                            "code" => nodes.push(Node::Code(parse_code_block(cur)?)),
583                            "js" => {
584                                let expr = parse_paren_expr(cur)?;
585                                nodes.push(Node::Js(expr));
586                            }
587                            "vitex" => {
588                                let entries = parse_vitex_args(cur)?;
589                                nodes.push(Node::Vitex(entries));
590                            }
591                            "can" => {
592                                let permission = parse_paren_expr(cur)?;
593                                let (then_branch, else_branch) = parse_else_tail(cur, "endcan")?;
594                                nodes.push(Node::Can {
595                                    permission,
596                                    then_branch,
597                                    else_branch,
598                                });
599                            }
600                            "role" => {
601                                let role = parse_paren_expr(cur)?;
602                                let (then_branch, else_branch) = parse_else_tail(cur, "endrole")?;
603                                nodes.push(Node::Role {
604                                    role,
605                                    then_branch,
606                                    else_branch,
607                                });
608                            }
609                            "spa" => {
610                                let (body, closer) = parse_nodes(cur)?;
611                                expect_closer(closer, "endspa")?;
612                                nodes.push(Node::Spa(body));
613                            }
614                            other => {
615                                return Err(ParseError::new(format!("unknown directive @{other}")))
616                            }
617                        }
618                    }
619                }
620            }
621        }
622    }
623}
624
625/// Reads raw Rust statements until `@endcode`. Unlike normal template
626/// bodies this is intentionally not parsed as markup: the macro validates
627/// the statements with `syn` when it expands the template.
628fn parse_code_block(cur: &mut Cursor) -> Result<String, ParseError> {
629    let rest = cur.rest();
630    let end = rest
631        .find("@endcode")
632        .ok_or_else(|| ParseError::new("unterminated @code block, expected @endcode"))?;
633    let code = rest[..end].to_string();
634    cur.advance(end + "@endcode".len());
635    Ok(code)
636}
637
638/// Parses everything after an `@if(...)`/`@elseif(...)` condition, through
639/// whichever of `@elseif`/`@else`/`@endif` ends it - recursively
640/// desugaring any `@elseif` chain into nested `Node::If`s in the else
641/// branch. `@if(a) X @elseif(b) Y @else Z @endif` builds exactly the same
642/// tree hand-nesting `@if(a) X @else @if(b) Y @else Z @endif @endif`
643/// would, so `larust-macros`' codegen needs no changes at all to support
644/// `@elseif` - it only ever sees plain, possibly-nested `Node::If`s.
645fn parse_if_tail(cur: &mut Cursor) -> Result<(Vec<Node>, Vec<Node>), ParseError> {
646    let (then_branch, closer) = parse_nodes(cur)?;
647    match closer {
648        Some(Closer { tag: "endif", .. }) => Ok((then_branch, Vec::new())),
649        Some(Closer { tag: "else", .. }) => {
650            let (else_branch, closer2) = parse_nodes(cur)?;
651            expect_closer(closer2, "endif")?;
652            Ok((then_branch, else_branch))
653        }
654        Some(Closer {
655            tag: "elseif",
656            elseif_cond: Some(cond),
657            ..
658        }) => {
659            let (nested_then, nested_else) = parse_if_tail(cur)?;
660            let else_branch = vec![Node::If {
661                cond,
662                then_branch: nested_then,
663                else_branch: nested_else,
664            }];
665            Ok((then_branch, else_branch))
666        }
667        other => Err(unexpected_closer(other, "@elseif, @else, or @endif")),
668    }
669}
670
671/// `@can`/`@role`'s own `parse_if_tail` - everything after the `(...)`
672/// argument, through whichever of `@else`/`@end{end_tag}` ends it. No
673/// `@elseif`-equivalent chain: unlike `@if`, `@can`/`@role` only ever test
674/// one permission/role name, so there's nothing for an "else-if" to chain
675/// against - a plain `@else` is the only alternate branch either supports.
676/// Kept separate from `parse_if_tail` (rather than generalizing that
677/// function with an `end_tag` parameter) so `@if`'s own `@elseif`-desugaring
678/// logic stays untouched and easy to reason about on its own.
679fn parse_else_tail(
680    cur: &mut Cursor,
681    end_tag: &'static str,
682) -> Result<(Vec<Node>, Vec<Node>), ParseError> {
683    let (then_branch, closer) = parse_nodes(cur)?;
684    match closer {
685        Some(Closer { tag, .. }) if tag == end_tag => Ok((then_branch, Vec::new())),
686        Some(Closer { tag: "else", .. }) => {
687            let (else_branch, closer2) = parse_nodes(cur)?;
688            expect_closer(closer2, end_tag)?;
689            Ok((then_branch, else_branch))
690        }
691        other => Err(unexpected_closer(other, &format!("@else or @{end_tag}"))),
692    }
693}
694
695fn parse_braces(cur: &mut Cursor, open: &str, close: &str) -> Result<String, ParseError> {
696    cur.advance(open.len());
697    let s = cur.rest();
698    let end = s
699        .find(close)
700        .ok_or_else(|| ParseError::new(format!("unterminated {open} ... {close}")))?;
701    let expr = s[..end].trim().to_string();
702    cur.advance(end + close.len());
703    Ok(expr)
704}
705
706fn parse_quoted_arg(cur: &mut Cursor) -> Result<String, ParseError> {
707    skip_ws(cur);
708    expect_char(cur, '(')?;
709    skip_ws(cur);
710    let value = parse_quoted_string(cur)?;
711    skip_ws(cur);
712    expect_char(cur, ')')?;
713    Ok(value)
714}
715
716/// Scans a `'...'`- or `"..."`-quoted string starting at the current
717/// position (the opening quote itself not yet consumed) - shared by
718/// `parse_quoted_arg` (`@extends('...')`, `@yield('...')`, ...) and
719/// `parse_wire_args` (`@wire('name', ...)`'s own name argument, which needs
720/// just the quoted-string scan without `parse_quoted_arg`'s surrounding
721/// `(`/`)` handling, since more may follow after the name).
722fn parse_quoted_string(cur: &mut Cursor) -> Result<String, ParseError> {
723    let quote = expect_one_of(cur, &['\'', '"'])?;
724
725    let s = cur.rest();
726    let end = s
727        .find(quote)
728        .ok_or_else(|| ParseError::new("unterminated string in directive argument"))?;
729    let value = s[..end].to_string();
730    cur.advance(end + 1); // consume through the closing quote
731    Ok(value)
732}
733
734/// Parses `@vitex(['path1', 'path2', ...])` - an array of quoted entry
735/// paths, matching Laravel's own `@vite([...])` syntax exactly (so a
736/// hand-converted template reads the same way the original did). Each
737/// entry is a plain quoted string via [`parse_quoted_string`] - no
738/// interpolation, no expression, same as every real `@vite(...)` call
739/// this exists to mirror. A trailing comma before `]` is tolerated
740/// (`['a', 'b',]`), matching how the array literal usually reads when
741/// hand-formatted across multiple lines.
742fn parse_vitex_args(cur: &mut Cursor) -> Result<Vec<String>, ParseError> {
743    skip_ws(cur);
744    expect_char(cur, '(')?;
745    skip_ws(cur);
746    expect_char(cur, '[')?;
747
748    let mut entries = Vec::new();
749    loop {
750        skip_ws(cur);
751        if cur.rest().starts_with(']') {
752            cur.advance(1);
753            break;
754        }
755        entries.push(parse_quoted_string(cur)?);
756        skip_ws(cur);
757        match cur.rest().chars().next() {
758            Some(',') => cur.advance(1),
759            Some(']') => {
760                cur.advance(1);
761                break;
762            }
763            _ => return Err(ParseError::new("expected ',' or ']' in @vitex([...])")),
764        }
765    }
766    skip_ws(cur);
767    expect_char(cur, ')')?;
768    if entries.is_empty() {
769        return Err(ParseError::new(
770            "@vitex([...]) needs at least one entry path",
771        ));
772    }
773    Ok(entries)
774}
775
776/// Parses `@global(name)` or `@global(name, fallback)`. `name` is a bare
777/// identifier, not a quoted string - deliberately different from
778/// `parse_quoted_arg`: it matches the bare-identifier style the `@globals`
779/// block itself uses (`title = "..."`), so the same literal token names the
780/// placeholder and its setter with no quote-mark mismatch between the two.
781/// `fallback`, when present, is any Rust expression up to the matching
782/// closing paren (via `scan_to_matching_close_paren`, shared with
783/// `parse_paren_expr`) - same free-form-expression convention as a
784/// `@globals` assignment's own right-hand side.
785fn parse_global_args(cur: &mut Cursor) -> Result<(String, Option<String>), ParseError> {
786    skip_ws(cur);
787    expect_char(cur, '(')?;
788    skip_ws(cur);
789
790    let s = cur.rest();
791    let end = s
792        .find(|c: char| !(c.is_alphanumeric() || c == '_'))
793        .unwrap_or(s.len());
794    if end == 0 {
795        return Err(ParseError::new("expected a variable name in @global(...)"));
796    }
797    let name = s[..end].to_string();
798    cur.advance(end);
799    skip_ws(cur);
800
801    match cur.rest().chars().next() {
802        Some(')') => {
803            cur.advance(1);
804            Ok((name, None))
805        }
806        Some(',') => {
807            cur.advance(1);
808            skip_ws(cur);
809            let fallback = scan_to_matching_close_paren(cur)?;
810            if fallback.is_empty() {
811                return Err(ParseError::new(
812                    "expected a fallback expression after ',' in @global(...)",
813                ));
814            }
815            Ok((name, Some(fallback)))
816        }
817        Some(c) => Err(ParseError::new(format!(
818            "expected ',' or ')' in @global(...), found '{c}'"
819        ))),
820        None => Err(ParseError::new(
821            "expected ',' or ')' in @global(...), found end of template",
822        )),
823    }
824}
825
826/// Parses an `@globals ... @endglobals` block body: one `name = expr`
827/// assignment per non-blank line. Deliberately **not** routed through
828/// `parse_nodes` - this is a different grammar (assignment lines), not
829/// blade markup - so it scans for the literal `"@endglobals"` marker the
830/// same way `parse_quoted_arg` scans for a closing quote, rather than
831/// tokenizing through the normal directive dispatch.
832fn parse_globals_block(cur: &mut Cursor) -> Result<Vec<GlobalEntry>, ParseError> {
833    let s = cur.rest();
834    let end = s
835        .find("@endglobals")
836        .ok_or_else(|| ParseError::new("unterminated @globals block, expected @endglobals"))?;
837    let block_text = &s[..end];
838    let entries = parse_globals_entries(block_text)?;
839    cur.advance(end + "@endglobals".len());
840    Ok(entries)
841}
842
843/// A `persist ` line prefix (a literal keyword, not a directive of its
844/// own) - see `GlobalEntry::persist`'s own doc comment. Checked *before*
845/// `find_assignment_split`, since `persist` never appears as part of a
846/// name/expression this line's own grammar would otherwise need to
847/// disambiguate from.
848const PERSIST_PREFIX: &str = "persist ";
849
850fn parse_globals_entries(block_text: &str) -> Result<Vec<GlobalEntry>, ParseError> {
851    let mut entries = Vec::new();
852    for line in block_text.lines() {
853        let line = line.trim();
854        if line.is_empty() {
855            continue;
856        }
857        let (persist, line) = match line.strip_prefix(PERSIST_PREFIX) {
858            Some(rest) => (true, rest.trim_start()),
859            None => (false, line),
860        };
861        let split = find_assignment_split(line).ok_or_else(|| {
862            ParseError::new(format!(
863                "expected `name = expression` in @globals block, found: `{line}`"
864            ))
865        })?;
866        let name = line[..split].trim().to_string();
867        let expr = line[split + 1..].trim().to_string();
868        // Must match the exact character class `parse_global_args` accepts
869        // for `@global(name)`'s own placeholder name - otherwise a typo'd
870        // or malformed name here (a stray space, a hyphen, ...) would
871        // silently register under a key no `@global(...)` placeholder
872        // could ever match, producing a dead map entry with no error at
873        // all: the placeholder would just quietly fall through to its
874        // `fallback` (or empty) instead.
875        if !is_valid_global_name(&name) {
876            return Err(ParseError::new(format!(
877                "invalid variable name `{name}` in @globals block - only letters, digits, and \
878                 underscores are allowed, matching @global(name)'s own placeholder syntax"
879            )));
880        }
881        if expr.is_empty() {
882            return Err(ParseError::new(format!(
883                "expected an expression after '=' in @globals block, found: `{line}`"
884            )));
885        }
886        entries.push(GlobalEntry {
887            name,
888            expr,
889            persist,
890        });
891    }
892    Ok(entries)
893}
894
895/// Parses `@wire('name')` or `@wire('name', { prop: expr, ... })`. The
896/// component name reuses `parse_quoted_string`, the same convention
897/// `@extends('...')` uses; the optional props object is a brace-delimited,
898/// comma-separated `key: expr` list - see `parse_prop_entries`.
899fn parse_wire_args(cur: &mut Cursor) -> Result<(String, Vec<(String, String)>), ParseError> {
900    parse_name_and_props_args(cur, "wire")
901}
902
903/// Parses `@resource('name')` or `@resource('name', { prop: expr, ... })` -
904/// the directive's opening arguments only; the `... @endresource` body
905/// (the slot) is parsed separately by the caller via the ordinary
906/// `parse_nodes` block-parsing path, same as `@section`/`@push`/`@loadonce`.
907/// Identical grammar to `@wire(...)`'s own arguments - shares the same
908/// scanner rather than duplicating it.
909fn parse_resource_args(cur: &mut Cursor) -> Result<(String, Vec<(String, String)>), ParseError> {
910    parse_name_and_props_args(cur, "resource")
911}
912
913/// Shared by `@wire(...)`/`@resource(...)`: `('name')` or
914/// `('name', { prop: expr, ... })`, with `directive` only used to name the
915/// directive in error messages.
916fn parse_name_and_props_args(
917    cur: &mut Cursor,
918    directive: &str,
919) -> Result<(String, Vec<(String, String)>), ParseError> {
920    skip_ws(cur);
921    expect_char(cur, '(')?;
922    skip_ws(cur);
923    let name = parse_quoted_string(cur)?;
924    skip_ws(cur);
925
926    match cur.rest().chars().next() {
927        Some(')') => {
928            cur.advance(1);
929            Ok((name, Vec::new()))
930        }
931        Some(',') => {
932            cur.advance(1);
933            skip_ws(cur);
934            expect_char(cur, '{')?;
935            let props = parse_prop_entries(cur)?;
936            skip_ws(cur);
937            expect_char(cur, ')')?;
938            Ok((name, props))
939        }
940        Some(c) => Err(ParseError::new(format!(
941            "expected ',' or ')' in @{directive}(...), found '{c}'"
942        ))),
943        None => Err(ParseError::new(format!(
944            "expected ',' or ')' in @{directive}(...), found end of template"
945        ))),
946    }
947}
948
949/// Parses `<resource:name attr="literal" :attr2="expr" />` (self-closing,
950/// empty slot) or `<resource:name ...>slot</resource:name>` (block form) -
951/// an alternate, HTML-tag-flavored surface syntax for exactly the same
952/// [`Node::Resource`] the `@resource('name', { ... }) ... @endresource`
953/// directive (`parse_resource_args` above) produces. Not a distinct AST
954/// concept: `resolve.rs` and `larust-macros`' codegen can't tell the two
955/// spellings apart, so a template can freely mix both, and adding this
956/// second spelling required zero changes outside this file.
957///
958/// Unprefixed attributes are literal string props - `title="Your profile."`
959/// becomes exactly the prop this file already builds for `{ title: "Your
960/// profile." }` (the raw attribute text re-escaped into a Rust string
961/// literal by `literal_attr_to_rust_string`). A leading `:` marks an
962/// attribute's value as a raw Rust expression instead - `:count="count"` is
963/// `{ count: count }` - Blade's own `<x-alert :message="$message">`
964/// convention. The cursor is positioned just past `<resource:` on entry.
965fn parse_resource_tag(cur: &mut Cursor) -> Result<Node, ParseError> {
966    cur.advance("<resource:".len());
967    let name = scan_tag_name(cur, "resource")?;
968    let props = parse_tag_attrs(cur, "resource")?;
969    skip_ws(cur);
970
971    if cur.rest().starts_with("/>") {
972        cur.advance(2);
973        return Ok(Node::Resource {
974            name,
975            props,
976            slot: Vec::new(),
977        });
978    }
979
980    expect_char(cur, '>')?;
981    let (slot, closer) = parse_nodes(cur)?;
982    match closer {
983        Some(Closer {
984            resource_tag_name: Some(found),
985            ..
986        }) if found == name => Ok(Node::Resource { name, props, slot }),
987        // A closing tag with the *wrong* name (a copy-paste/rename slip)
988        // is deliberately distinguished from "no closing tag at all" here
989        // - both fall into this arm, but `unexpected_closer` renders each
990        // found-value distinctly, so the error names exactly what was
991        // actually found (a mismatched tag, an unrelated `@endXxx`
992        // bubbling up from an unbalanced directive inside the slot, or
993        // end of template).
994        other => Err(unexpected_closer(other, &format!("</resource:{name}>"))),
995    }
996}
997
998/// Parses `<wire:name attr="literal" :attr2="expr" />` - the HTML-tag-
999/// flavored counterpart to `@wire('name', { ... })`, producing the
1000/// identical [`Node::Wire`]. **Always self-closing** - unlike
1001/// `<resource:...>`, `@wire(...)` has never had a body/slot concept at
1002/// all (a mounted component renders entirely from its own template), so
1003/// there's no block form to support, and a stray non-self-closing `>` is
1004/// a clear error rather than being silently accepted as "no slot". The
1005/// cursor is positioned just past `<wire:` on entry.
1006fn parse_wire_tag(cur: &mut Cursor) -> Result<Node, ParseError> {
1007    cur.advance("<wire:".len());
1008    let name = scan_tag_name(cur, "wire")?;
1009    let props = parse_tag_attrs(cur, "wire")?;
1010    skip_ws(cur);
1011
1012    if cur.rest().starts_with("/>") {
1013        cur.advance(2);
1014        return Ok(Node::Wire { name, props });
1015    }
1016
1017    match cur.rest().chars().next() {
1018        Some('>') => Err(ParseError::new(format!(
1019            "<wire:{name}> must be self-closing ('/>') - it has no closing tag or slot, \
1020             unlike <resource:...>"
1021        ))),
1022        Some(c) => Err(ParseError::new(format!(
1023            "expected '/>' to close <wire:{name}>, found '{c}'"
1024        ))),
1025        None => Err(ParseError::new(format!(
1026            "expected '/>' to close <wire:{name}>, found end of template"
1027        ))),
1028    }
1029}
1030
1031/// Scans a tag's dotted name (`components.panel` after `<resource:`, or a
1032/// component name after `<wire:`) - any run of non-whitespace characters
1033/// up to `/`, `>`, or whitespace. Deliberately no character-class
1034/// validation, same "an invalid name surfaces later, not here" stance the
1035/// directive-syntax name arguments already take. `tag_prefix`
1036/// (`"resource"`/`"wire"`) is only used to phrase the error message.
1037fn scan_tag_name(cur: &mut Cursor, tag_prefix: &str) -> Result<String, ParseError> {
1038    let s = cur.rest();
1039    let end = s
1040        .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
1041        .unwrap_or(s.len());
1042    if end == 0 {
1043        return Err(ParseError::new(format!(
1044            "expected a name after '<{tag_prefix}:'"
1045        )));
1046    }
1047    let name = s[..end].to_string();
1048    cur.advance(end);
1049    Ok(name)
1050}
1051
1052/// Parses zero or more `name="literal"` / `:name="expr"` attributes,
1053/// stopping (without consuming) at the tag's own closing `/>` or `>` -
1054/// shared by `<resource:...>` and `<wire:...>`, whose attribute grammar is
1055/// identical; `tag_prefix` is only used to phrase an error message.
1056fn parse_tag_attrs(
1057    cur: &mut Cursor,
1058    tag_prefix: &str,
1059) -> Result<Vec<(String, String)>, ParseError> {
1060    let mut props = Vec::new();
1061    loop {
1062        skip_ws(cur);
1063        match cur.rest().chars().next() {
1064            Some('/') | Some('>') | None => return Ok(props),
1065            _ => {}
1066        }
1067
1068        let dynamic = cur.rest().starts_with(':');
1069        if dynamic {
1070            cur.advance(1);
1071        }
1072
1073        let s = cur.rest();
1074        let end = s
1075            .find(|c: char| !(c.is_alphanumeric() || c == '_'))
1076            .unwrap_or(s.len());
1077        if end == 0 {
1078            return Err(ParseError::new(format!(
1079                "expected an attribute name in <{tag_prefix}:...> tag"
1080            )));
1081        }
1082        let attr_name = s[..end].to_string();
1083        cur.advance(end);
1084        skip_ws(cur);
1085        expect_char(cur, '=')?;
1086        skip_ws(cur);
1087        let raw_value = parse_quoted_string(cur)?;
1088
1089        let expr = if dynamic {
1090            raw_value
1091        } else {
1092            literal_attr_to_rust_string(&raw_value)
1093        };
1094        props.push((attr_name, expr));
1095    }
1096}
1097
1098/// Wraps raw, unescaped attribute text in a valid Rust string literal -
1099/// `Your profile.` becomes `"Your profile."`, `Say "hi"` becomes `"Say
1100/// \"hi\""`. Only `"` and `\` need escaping; Rust string literals otherwise
1101/// accept any character (including a literal newline) as-is.
1102fn literal_attr_to_rust_string(raw: &str) -> String {
1103    let mut escaped = String::with_capacity(raw.len() + 2);
1104    escaped.push('"');
1105    for c in raw.chars() {
1106        match c {
1107            '"' => escaped.push_str("\\\""),
1108            '\\' => escaped.push_str("\\\\"),
1109            _ => escaped.push(c),
1110        }
1111    }
1112    escaped.push('"');
1113    escaped
1114}
1115
1116/// Parses the interior of a `@wire(..., { key: expr, key2: expr2 })` props
1117/// object - cursor positioned just past the opening `{`. Scans to the
1118/// matching `}`, tracking one *combined* nesting depth over `(`/`{`/`[`
1119/// (any of which may legitimately appear inside a prop's own expression,
1120/// e.g. `{ items: vec![1, 2] }`) rather than three independent counters -
1121/// sufficient here because this scan only needs to know when it's back at
1122/// the outer object's own closing brace, not whether the source's brackets
1123/// are individually well-matched (a genuine mismatch surfaces later as a
1124/// `syn::parse_str` error in `larust-macros` instead). Quoted spans are
1125/// skipped with the same backslash-escape handling as
1126/// `scan_to_matching_close_paren`. Advances the cursor past the matching
1127/// closing `}`.
1128fn parse_prop_entries(cur: &mut Cursor) -> Result<Vec<(String, String)>, ParseError> {
1129    let s = cur.rest();
1130    let mut depth: i32 = 1;
1131    let mut end = None;
1132    let mut chars = s.char_indices().peekable();
1133
1134    while let Some((i, c)) = chars.next() {
1135        match c {
1136            '(' | '{' | '[' => depth += 1,
1137            ')' | '}' | ']' => {
1138                depth -= 1;
1139                if depth == 0 {
1140                    end = Some(i);
1141                    break;
1142                }
1143            }
1144            '"' | '\'' => {
1145                let quote = c;
1146                while let Some((_, ch)) = chars.next() {
1147                    if ch == '\\' {
1148                        chars.next();
1149                        continue;
1150                    }
1151                    if ch == quote {
1152                        break;
1153                    }
1154                }
1155            }
1156            _ => {}
1157        }
1158    }
1159
1160    let end = end.ok_or_else(|| ParseError::new("unterminated '{' in @wire(...) props"))?;
1161    let body = &s[..end];
1162    cur.advance(end + 1);
1163
1164    split_prop_entries(body)
1165}
1166
1167/// Splits a `@wire(...)` props body on top-level commas (honoring nested
1168/// brackets and quoted spans, same depth-tracking technique as
1169/// `parse_prop_entries`) and each resulting entry on its first colon (via
1170/// `find_prop_colon`). A trailing comma, or an entirely empty `{}`, leaves
1171/// a blank final segment - tolerated, not an error, matching
1172/// `parse_globals_entries`'s own "blank lines are skipped" convention.
1173fn split_prop_entries(body: &str) -> Result<Vec<(String, String)>, ParseError> {
1174    let mut entries = Vec::new();
1175    let mut depth: i32 = 0;
1176    let mut start = 0;
1177    let bytes = body.as_bytes();
1178    let mut i = 0;
1179
1180    while i < bytes.len() {
1181        match bytes[i] {
1182            b'(' | b'{' | b'[' => {
1183                depth += 1;
1184                i += 1;
1185            }
1186            b')' | b'}' | b']' => {
1187                depth -= 1;
1188                i += 1;
1189            }
1190            b'"' | b'\'' => {
1191                let quote = bytes[i];
1192                i += 1;
1193                while i < bytes.len() {
1194                    if bytes[i] == b'\\' {
1195                        i += 2;
1196                        continue;
1197                    }
1198                    if bytes[i] == quote {
1199                        i += 1;
1200                        break;
1201                    }
1202                    i += 1;
1203                }
1204            }
1205            b',' if depth == 0 => {
1206                push_prop_entry(&mut entries, body[start..i].trim())?;
1207                start = i + 1;
1208                i += 1;
1209            }
1210            _ => i += 1,
1211        }
1212    }
1213    push_prop_entry(&mut entries, body[start..].trim())?;
1214
1215    Ok(entries)
1216}
1217
1218fn push_prop_entry(entries: &mut Vec<(String, String)>, entry: &str) -> Result<(), ParseError> {
1219    if entry.is_empty() {
1220        return Ok(());
1221    }
1222    let split = find_prop_colon(entry).ok_or_else(|| {
1223        ParseError::new(format!(
1224            "expected `name: expression` in @wire(...) props, found: `{entry}`"
1225        ))
1226    })?;
1227    let name = entry[..split].trim().to_string();
1228    let expr = entry[split + 1..].trim().to_string();
1229    // Same character class `@global`/`@globals` already require of a
1230    // placeholder name - kept consistent rather than inventing a separate
1231    // rule for prop names.
1232    if !is_valid_global_name(&name) {
1233        return Err(ParseError::new(format!(
1234            "invalid prop name `{name}` in @wire(...) - only letters, digits, and underscores \
1235             are allowed"
1236        )));
1237    }
1238    if expr.is_empty() {
1239        return Err(ParseError::new(format!(
1240            "expected an expression after ':' in @wire(...) props, found: `{entry}`"
1241        )));
1242    }
1243    entries.push((name, expr));
1244    Ok(())
1245}
1246
1247/// Finds the byte offset of the first unquoted `:` in a prop entry. Unlike
1248/// `find_assignment_split`, no operator-adjacency check (for `!=`/`<=`/...)
1249/// or bracket-depth tracking is needed: a prop key is required to be a bare
1250/// identifier immediately before this colon, and identifiers can't contain
1251/// `:` or brackets, so the first unquoted `:` encountered while scanning
1252/// left-to-right is always the key/value separator - never a `::` path
1253/// separator or a nested struct-literal field's own colon, both of which
1254/// can only occur *after* it, inside `expr`.
1255fn find_prop_colon(entry: &str) -> Option<usize> {
1256    let bytes = entry.as_bytes();
1257    let mut i = 0;
1258    while i < bytes.len() {
1259        match bytes[i] {
1260            b'"' | b'\'' => {
1261                let quote = bytes[i];
1262                i += 1;
1263                while i < bytes.len() {
1264                    if bytes[i] == b'\\' {
1265                        i += 2;
1266                        continue;
1267                    }
1268                    if bytes[i] == quote {
1269                        i += 1;
1270                        break;
1271                    }
1272                    i += 1;
1273                }
1274            }
1275            b':' => return Some(i),
1276            _ => i += 1,
1277        }
1278    }
1279    None
1280}
1281
1282/// The same character class `parse_global_args` accepts for `@global(name)` -
1283/// alphanumeric plus underscore, non-empty. Shared so a `@globals` block's
1284/// assignment names can never silently drift out of sync with what a
1285/// `@global(...)` placeholder is able to match.
1286fn is_valid_global_name(name: &str) -> bool {
1287    !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
1288}
1289
1290/// Finds the byte offset of the assignment `=` in a `name = expr` line -
1291/// not just the first `=`, since `expr` may itself contain comparison
1292/// operators (`==`, `!=`, `<=`, `>=`) or a string literal containing `=`.
1293/// Skips over quoted spans (with the same backslash-escape handling as
1294/// `parse_paren_expr`) and rejects an `=` that's adjacent to `!`/`<`/`>`/`=`
1295/// (part of a multi-char operator), returning the first truly isolated `=`.
1296fn find_assignment_split(line: &str) -> Option<usize> {
1297    let bytes = line.as_bytes();
1298    let mut i = 0;
1299    while i < bytes.len() {
1300        match bytes[i] {
1301            b'"' | b'\'' => {
1302                let quote = bytes[i];
1303                i += 1;
1304                while i < bytes.len() {
1305                    if bytes[i] == b'\\' {
1306                        i += 2;
1307                        continue;
1308                    }
1309                    if bytes[i] == quote {
1310                        i += 1;
1311                        break;
1312                    }
1313                    i += 1;
1314                }
1315            }
1316            b'=' => {
1317                let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
1318                let next = bytes.get(i + 1).copied();
1319                let is_operator_char =
1320                    |b: Option<u8>| matches!(b, Some(b'!') | Some(b'<') | Some(b'>') | Some(b'='));
1321                if !is_operator_char(prev) && next != Some(b'=') {
1322                    return Some(i);
1323                }
1324                i += 1;
1325            }
1326            _ => i += 1,
1327        }
1328    }
1329    None
1330}
1331
1332/// Extracts the balanced, string-literal-aware contents of `@if(...)` /
1333/// `@foreach(...)` - an expression may itself contain parens
1334/// (`user.age > (18 + 1)`) or a string literal containing a paren
1335/// (`name == "(test)"`), both of which must not prematurely close the
1336/// directive's own outer parens.
1337fn parse_paren_expr(cur: &mut Cursor) -> Result<String, ParseError> {
1338    skip_ws(cur);
1339    expect_char(cur, '(')?;
1340    scan_to_matching_close_paren(cur)
1341}
1342
1343/// Scans from the current position - already *past* an opening `(` - to its
1344/// matching `)`, honoring nested parens and string literals containing
1345/// parens (`user.age > (18 + 1)`, `name == "(test)"`), and advances the
1346/// cursor past that closing paren. Shared by `parse_paren_expr` (for
1347/// `@if(...)`/`@foreach(...)`) and `parse_global_args` (for `@global(name,
1348/// fallback)`'s optional second argument).
1349fn scan_to_matching_close_paren(cur: &mut Cursor) -> Result<String, ParseError> {
1350    let s = cur.rest();
1351    let mut depth: i32 = 1;
1352    let mut end = None;
1353    let mut chars = s.char_indices().peekable();
1354
1355    while let Some((i, c)) = chars.next() {
1356        match c {
1357            '(' => depth += 1,
1358            ')' => {
1359                depth -= 1;
1360                if depth == 0 {
1361                    end = Some(i);
1362                    break;
1363                }
1364            }
1365            '"' | '\'' => {
1366                let quote = c;
1367                while let Some((_, ch)) = chars.next() {
1368                    if ch == '\\' {
1369                        chars.next();
1370                        continue;
1371                    }
1372                    if ch == quote {
1373                        break;
1374                    }
1375                }
1376            }
1377            _ => {}
1378        }
1379    }
1380
1381    let end = end.ok_or_else(|| ParseError::new("unterminated '(' in directive"))?;
1382    let expr = s[..end].trim().to_string();
1383    cur.advance(end + 1);
1384    Ok(expr)
1385}
1386
1387fn split_foreach(raw: &str) -> Result<(String, String), ParseError> {
1388    let idx = raw
1389        .find(" in ")
1390        .ok_or_else(|| ParseError::new("expected `binding in iterable` in @foreach(...)"))?;
1391    let binding = raw[..idx].trim().to_string();
1392    let iter = raw[idx + 4..].trim().to_string();
1393    if binding.is_empty() || iter.is_empty() {
1394        return Err(ParseError::new(
1395            "expected `binding in iterable` in @foreach(...)",
1396        ));
1397    }
1398    Ok((binding, iter))
1399}
1400
1401fn expect_closer(found: Option<Closer>, expected: &str) -> Result<(), ParseError> {
1402    match found {
1403        Some(Closer { tag, .. }) if tag == expected => Ok(()),
1404        other => Err(unexpected_closer(other, &format!("@{expected}"))),
1405    }
1406}
1407
1408fn unexpected_closer(found: Option<Closer>, expected: &str) -> ParseError {
1409    match found {
1410        Some(closer) => ParseError::new(format!(
1411            "expected {expected}, found {}",
1412            describe_closer(&closer)
1413        )),
1414        None => ParseError::new(format!("unexpected end of template, expected {expected}")),
1415    }
1416}
1417
1418/// Renders a `Closer` the way it should read in an error message - a
1419/// resource-tag closer as `</resource:name>`, every other closer as
1420/// `@tag`, matching how each was actually spelled in the template.
1421fn describe_closer(closer: &Closer) -> String {
1422    match &closer.resource_tag_name {
1423        Some(name) => format!("</resource:{name}>"),
1424        None => format!("@{}", closer.tag),
1425    }
1426}
1427
1428fn skip_ws(cur: &mut Cursor) {
1429    let n = cur.rest().len() - cur.rest().trim_start().len();
1430    cur.advance(n);
1431}
1432
1433fn expect_char(cur: &mut Cursor, expected: char) -> Result<(), ParseError> {
1434    match cur.rest().chars().next() {
1435        Some(c) if c == expected => {
1436            cur.advance(c.len_utf8());
1437            Ok(())
1438        }
1439        Some(c) => Err(ParseError::new(format!(
1440            "expected '{expected}', found '{c}'"
1441        ))),
1442        None => Err(ParseError::new(format!(
1443            "expected '{expected}', found end of template"
1444        ))),
1445    }
1446}
1447
1448fn expect_one_of(cur: &mut Cursor, options: &[char]) -> Result<char, ParseError> {
1449    match cur.rest().chars().next() {
1450        Some(c) if options.contains(&c) => {
1451            cur.advance(c.len_utf8());
1452            Ok(c)
1453        }
1454        Some(c) => Err(ParseError::new(format!(
1455            "expected one of {options:?}, found '{c}'"
1456        ))),
1457        None => Err(ParseError::new(format!(
1458            "expected one of {options:?}, found end of template"
1459        ))),
1460    }
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465    use super::*;
1466
1467    #[test]
1468    fn parses_plain_text() {
1469        let nodes = parse("hello world").unwrap();
1470        assert_eq!(nodes, vec![Node::Text("hello world".to_string())]);
1471    }
1472
1473    #[test]
1474    fn parses_escaped_interpolation() {
1475        let nodes = parse("Hi {{ user.name }}!").unwrap();
1476        assert_eq!(
1477            nodes,
1478            vec![
1479                Node::Text("Hi ".to_string()),
1480                Node::Interpolate {
1481                    expr: "user.name".to_string(),
1482                    escape: true
1483                },
1484                Node::Text("!".to_string()),
1485            ]
1486        );
1487    }
1488
1489    #[test]
1490    fn parses_raw_interpolation() {
1491        let nodes = parse("{!! html !!}").unwrap();
1492        assert_eq!(
1493            nodes,
1494            vec![Node::Interpolate {
1495                expr: "html".to_string(),
1496                escape: false
1497            }]
1498        );
1499    }
1500
1501    #[test]
1502    fn a_comment_produces_no_node_at_all() {
1503        let nodes = parse("before {{-- a comment --}} after").unwrap();
1504        assert_eq!(
1505            nodes,
1506            vec![
1507                Node::Text("before ".to_string()),
1508                Node::Text(" after".to_string()),
1509            ]
1510        );
1511    }
1512
1513    #[test]
1514    fn a_directive_name_mentioned_inside_a_js_line_comment_in_a_script_block_is_plain_text() {
1515        // The exact real-world shape that broke a real template: an
1516        // explanatory `//` comment inside a `<script>` block mentioning a
1517        // directive by name, previously parsed exactly like a live
1518        // `@push`/`@wire` directive instead of staying inert prose.
1519        let source = "<script>\n// see @push('head') and @wire for how this works\nconsole.log(1);\n</script>";
1520        let nodes = parse(source).unwrap();
1521        assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1522    }
1523
1524    #[test]
1525    fn a_directive_name_inside_a_js_block_comment_in_a_script_block_is_plain_text() {
1526        let source = "<script>/* uses @extends internally, not really */\nfoo();</script>";
1527        let nodes = parse(source).unwrap();
1528        assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1529    }
1530
1531    #[test]
1532    fn a_directive_name_inside_an_html_comment_is_plain_text_even_outside_a_script_block() {
1533        let source = "<!-- this page used to use @extends but doesn't anymore --><p>hi</p>";
1534        let nodes = parse(source).unwrap();
1535        assert_eq!(nodes, vec![Node::Text(source.to_string())]);
1536    }
1537
1538    #[test]
1539    fn a_url_containing_double_slashes_inside_a_script_block_does_not_swallow_a_real_directive_after_it(
1540    ) {
1541        // A `//` inside a string literal (a URL, most commonly) must never
1542        // be mistaken for the start of a JS comment - if it were, `@csrf`
1543        // right after it on the same line would incorrectly be treated as
1544        // still "inside a comment" and never recognized as the real
1545        // directive it is.
1546        let source = r#"<script>var url = "http://example.com"; </script>@csrf"#;
1547        let nodes = parse(source).unwrap();
1548        assert_eq!(
1549            nodes,
1550            vec![
1551                Node::Text(r#"<script>var url = "http://example.com"; </script>"#.to_string()),
1552                Node::Csrf,
1553            ]
1554        );
1555    }
1556
1557    #[test]
1558    fn a_real_directive_still_works_normally_inside_a_script_block() {
1559        // The fix must not accidentally suppress *real* directive usage
1560        // inside a `<script>` tag - template-value injection into embedded
1561        // JS (`const x = {{ value }};`) is a genuine, supported pattern,
1562        // and this proves an `@`-directive works there too.
1563        let nodes = parse("<script>@csrf</script>").unwrap();
1564        assert_eq!(
1565            nodes,
1566            vec![
1567                Node::Text("<script>".to_string()),
1568                Node::Csrf,
1569                Node::Text("</script>".to_string()),
1570            ]
1571        );
1572    }
1573
1574    #[test]
1575    fn a_double_slash_outside_any_script_block_is_never_treated_as_a_comment() {
1576        // `in_script` gates JS-comment detection specifically so a URL in
1577        // plain HTML prose (never inside a `<script>` tag) is completely
1578        // unaffected by this fix - `//` there was never a directive
1579        // marker to begin with, and still isn't.
1580        let nodes = parse("Visit https://example.com for more, then @csrf").unwrap();
1581        assert_eq!(
1582            nodes,
1583            vec![
1584                Node::Text("Visit https://example.com for more, then ".to_string()),
1585                Node::Csrf,
1586            ]
1587        );
1588    }
1589
1590    #[test]
1591    fn a_comment_containing_its_own_interpolation_and_directive_syntax_is_fully_inert() {
1592        // The whole reason this needs to be its own atomic scan rather
1593        // than plain dead text: a naive HTML-comment-shaped passthrough
1594        // would leave `{{ post.title }}`/`@if(...)`/`@endif` sitting in
1595        // the output as *live* syntax for this exact parser to re-scan
1596        // and misinterpret later. Consuming the comment's whole span in
1597        // one step means none of this is ever seen as real directives.
1598        let nodes = parse("{{-- @if(ok) {{ post.title }} @endif --}}{{ real }}").unwrap();
1599        assert_eq!(
1600            nodes,
1601            vec![Node::Interpolate {
1602                expr: "real".to_string(),
1603                escape: true
1604            }]
1605        );
1606    }
1607
1608    #[test]
1609    fn a_comment_can_contain_a_literal_dashes_close_brace_sequence_only_at_its_own_end() {
1610        // Blade comments aren't nestable - the *first* `--}}` always
1611        // closes one, matching real Blade semantics exactly (and this
1612        // parser's own pre-existing `Marker::BladeComment` handling in
1613        // `larust-convert`, which this mirrors).
1614        let nodes = parse("{{-- one --}}{{-- two --}}").unwrap();
1615        assert_eq!(nodes, Vec::<Node>::new());
1616    }
1617
1618    #[test]
1619    fn an_unterminated_comment_is_a_clear_error() {
1620        let err = parse("{{-- never closed").unwrap_err();
1621        assert!(
1622            err.to_string().contains("unterminated"),
1623            "message was: {err}"
1624        );
1625    }
1626
1627    #[test]
1628    fn a_comment_takes_precedence_over_a_plain_double_brace_at_the_same_position() {
1629        // `{{--` and `{{` both match at the same starting offset for a
1630        // real comment - `next_marker` must prefer the more specific
1631        // `Comment` interpretation, not misparse it as an `Interpolate`
1632        // whose "expression" text starts with two stray dashes.
1633        let nodes = parse("{{-- x --}}").unwrap();
1634        assert_eq!(nodes, Vec::<Node>::new());
1635    }
1636
1637    #[test]
1638    fn literal_at_sign_is_not_a_directive() {
1639        let nodes = parse("contact: hello@example.com").unwrap();
1640        assert_eq!(
1641            nodes,
1642            vec![Node::Text("contact: hello@example.com".to_string())]
1643        );
1644    }
1645
1646    #[test]
1647    fn parses_if_else() {
1648        let nodes = parse("@if(user.admin) admin @else guest @endif").unwrap();
1649        assert_eq!(
1650            nodes,
1651            vec![Node::If {
1652                cond: "user.admin".to_string(),
1653                then_branch: vec![Node::Text(" admin ".to_string())],
1654                else_branch: vec![Node::Text(" guest ".to_string())],
1655            }]
1656        );
1657    }
1658
1659    #[test]
1660    fn parses_if_without_else() {
1661        let nodes = parse("@if(ok) yes @endif").unwrap();
1662        assert_eq!(
1663            nodes,
1664            vec![Node::If {
1665                cond: "ok".to_string(),
1666                then_branch: vec![Node::Text(" yes ".to_string())],
1667                else_branch: vec![],
1668            }]
1669        );
1670    }
1671
1672    #[test]
1673    fn parses_elseif_without_a_trailing_else() {
1674        // Desugars into a nested `Node::If` in the else branch - exactly
1675        // what hand-nesting `@if(a) X @else @if(b) Y @endif @endif` would
1676        // produce, proving `@elseif` needs no codegen changes.
1677        let nodes = parse("@if(a) X @elseif(b) Y @endif").unwrap();
1678        assert_eq!(
1679            nodes,
1680            vec![Node::If {
1681                cond: "a".to_string(),
1682                then_branch: vec![Node::Text(" X ".to_string())],
1683                else_branch: vec![Node::If {
1684                    cond: "b".to_string(),
1685                    then_branch: vec![Node::Text(" Y ".to_string())],
1686                    else_branch: vec![],
1687                }],
1688            }]
1689        );
1690    }
1691
1692    #[test]
1693    fn parses_elseif_with_a_trailing_else() {
1694        let nodes = parse("@if(a) X @elseif(b) Y @else Z @endif").unwrap();
1695        assert_eq!(
1696            nodes,
1697            vec![Node::If {
1698                cond: "a".to_string(),
1699                then_branch: vec![Node::Text(" X ".to_string())],
1700                else_branch: vec![Node::If {
1701                    cond: "b".to_string(),
1702                    then_branch: vec![Node::Text(" Y ".to_string())],
1703                    else_branch: vec![Node::Text(" Z ".to_string())],
1704                }],
1705            }]
1706        );
1707    }
1708
1709    #[test]
1710    fn parses_a_chain_of_multiple_elseifs() {
1711        let nodes = parse("@if(a) X @elseif(b) Y @elseif(c) Z @else W @endif").unwrap();
1712        assert_eq!(
1713            nodes,
1714            vec![Node::If {
1715                cond: "a".to_string(),
1716                then_branch: vec![Node::Text(" X ".to_string())],
1717                else_branch: vec![Node::If {
1718                    cond: "b".to_string(),
1719                    then_branch: vec![Node::Text(" Y ".to_string())],
1720                    else_branch: vec![Node::If {
1721                        cond: "c".to_string(),
1722                        then_branch: vec![Node::Text(" Z ".to_string())],
1723                        else_branch: vec![Node::Text(" W ".to_string())],
1724                    }],
1725                }],
1726            }]
1727        );
1728    }
1729
1730    #[test]
1731    fn parses_a_chain_of_multiple_elseifs_with_no_trailing_else() {
1732        let nodes = parse("@if(a) X @elseif(b) Y @elseif(c) Z @endif").unwrap();
1733        assert_eq!(
1734            nodes,
1735            vec![Node::If {
1736                cond: "a".to_string(),
1737                then_branch: vec![Node::Text(" X ".to_string())],
1738                else_branch: vec![Node::If {
1739                    cond: "b".to_string(),
1740                    then_branch: vec![Node::Text(" Y ".to_string())],
1741                    else_branch: vec![Node::If {
1742                        cond: "c".to_string(),
1743                        then_branch: vec![Node::Text(" Z ".to_string())],
1744                        else_branch: vec![],
1745                    }],
1746                }],
1747            }]
1748        );
1749    }
1750
1751    #[test]
1752    fn stray_elseif_at_top_level_is_a_clear_error() {
1753        let err = parse("hi @elseif(x) there @endif").unwrap_err();
1754        assert!(err.to_string().contains("no matching opening directive"));
1755    }
1756
1757    #[test]
1758    fn if_condition_handles_nested_parens_and_strings() {
1759        let nodes = parse(r#"@if(user.name == "a)b") x @endif"#).unwrap();
1760        let Node::If { cond, .. } = &nodes[0] else {
1761            panic!("expected If node");
1762        };
1763        assert_eq!(cond, r#"user.name == "a)b""#);
1764    }
1765
1766    #[test]
1767    fn parses_foreach() {
1768        let nodes = parse("@foreach(post in posts){{ post }}@endforeach").unwrap();
1769        assert_eq!(
1770            nodes,
1771            vec![Node::Foreach {
1772                binding: "post".to_string(),
1773                iter: "posts".to_string(),
1774                body: vec![Node::Interpolate {
1775                    expr: "post".to_string(),
1776                    escape: true
1777                }],
1778            }]
1779        );
1780    }
1781
1782    #[test]
1783    fn parses_foreach_with_a_keyed_tuple_binding() {
1784        let nodes =
1785            parse("@foreach((key, item) in items.iter().enumerate()){{ key }}@endforeach").unwrap();
1786        assert_eq!(
1787            nodes,
1788            vec![Node::Foreach {
1789                binding: "(key, item)".to_string(),
1790                iter: "items.iter().enumerate()".to_string(),
1791                body: vec![Node::Interpolate {
1792                    expr: "key".to_string(),
1793                    escape: true
1794                }],
1795            }]
1796        );
1797    }
1798
1799    #[test]
1800    fn parses_extends_section_yield() {
1801        let nodes = parse("@extends('layouts.app')@section('content')hi@endsection").unwrap();
1802        assert_eq!(
1803            nodes,
1804            vec![
1805                Node::Extends("layouts.app".to_string()),
1806                Node::Section {
1807                    name: "content".to_string(),
1808                    body: vec![Node::Text("hi".to_string())],
1809                },
1810            ]
1811        );
1812
1813        let nodes = parse("@yield('content')").unwrap();
1814        assert_eq!(nodes, vec![Node::Yield("content".to_string())]);
1815    }
1816
1817    #[test]
1818    fn parses_push_and_stack() {
1819        let nodes = parse("@push('scripts')<script></script>@endpush").unwrap();
1820        assert_eq!(
1821            nodes,
1822            vec![Node::Push {
1823                name: "scripts".to_string(),
1824                body: vec![Node::Text("<script></script>".to_string())],
1825            }]
1826        );
1827
1828        let nodes = parse("@stack('scripts')").unwrap();
1829        assert_eq!(nodes, vec![Node::Stack("scripts".to_string())]);
1830    }
1831
1832    #[test]
1833    fn stray_endpush_without_a_matching_push_is_a_clear_error() {
1834        let err = parse("hi @endpush there").unwrap_err();
1835        assert!(err.to_string().contains("no matching opening directive"));
1836    }
1837
1838    #[test]
1839    fn parses_csrf() {
1840        let nodes = parse("<form>@csrf</form>").unwrap();
1841        assert_eq!(
1842            nodes,
1843            vec![
1844                Node::Text("<form>".to_string()),
1845                Node::Csrf,
1846                Node::Text("</form>".to_string()),
1847            ]
1848        );
1849    }
1850
1851    #[test]
1852    fn mismatched_closer_is_a_clear_error() {
1853        let err = parse("@if(x) hi @endforeach").unwrap_err();
1854        let message = err.to_string();
1855        assert!(message.contains("@endif"), "message was: {message}");
1856        assert!(
1857            message.contains("found @endforeach"),
1858            "message was: {message}"
1859        );
1860    }
1861
1862    #[test]
1863    fn unclosed_directive_is_a_clear_error() {
1864        let err = parse("@if(x) hi").unwrap_err();
1865        assert!(err.to_string().contains("unexpected end of template"));
1866    }
1867
1868    #[test]
1869    fn stray_closer_at_top_level_is_a_clear_error() {
1870        let err = parse("hi @endif").unwrap_err();
1871        assert!(err.to_string().contains("no matching opening directive"));
1872    }
1873
1874    #[test]
1875    fn parses_global_placeholder() {
1876        let nodes = parse("<title>@global(title)</title>").unwrap();
1877        assert_eq!(
1878            nodes,
1879            vec![
1880                Node::Text("<title>".to_string()),
1881                Node::Global {
1882                    name: "title".to_string(),
1883                    fallback: None,
1884                },
1885                Node::Text("</title>".to_string()),
1886            ]
1887        );
1888    }
1889
1890    #[test]
1891    fn parses_global_placeholder_with_a_fallback() {
1892        let nodes = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
1893        assert_eq!(
1894            nodes,
1895            vec![
1896                Node::Text("<title>".to_string()),
1897                Node::Global {
1898                    name: "title".to_string(),
1899                    fallback: Some("\"Larust\"".to_string()),
1900                },
1901                Node::Text("</title>".to_string()),
1902            ]
1903        );
1904    }
1905
1906    #[test]
1907    fn global_fallback_can_be_an_arbitrary_expression_with_nested_parens_and_strings() {
1908        let nodes = parse(r#"@global(title, default_title(page, "(fallback)"))"#).unwrap();
1909        assert_eq!(
1910            nodes,
1911            vec![Node::Global {
1912                name: "title".to_string(),
1913                fallback: Some(r#"default_title(page, "(fallback)")"#.to_string()),
1914            }]
1915        );
1916    }
1917
1918    #[test]
1919    fn missing_comma_or_close_paren_in_global_is_a_clear_error() {
1920        let err = parse("@global(title 'oops')").unwrap_err();
1921        assert!(
1922            err.to_string().contains("expected ',' or ')'"),
1923            "message was: {err}"
1924        );
1925    }
1926
1927    #[test]
1928    fn parses_globals_block_with_multiple_assignments() {
1929        let nodes = parse(
1930            "@globals\n\
1931             title = \"My Page\"\n\
1932             canonical = \"https://example.com\"\n\
1933             @endglobals",
1934        )
1935        .unwrap();
1936        assert_eq!(
1937            nodes,
1938            vec![Node::Globals(vec![
1939                GlobalEntry {
1940                    name: "title".to_string(),
1941                    expr: "\"My Page\"".to_string(),
1942                    persist: false,
1943                },
1944                GlobalEntry {
1945                    name: "canonical".to_string(),
1946                    expr: "\"https://example.com\"".to_string(),
1947                    persist: false,
1948                },
1949            ])]
1950        );
1951    }
1952
1953    #[test]
1954    fn globals_block_skips_blank_lines() {
1955        let nodes = parse("@globals\n\ntitle = \"x\"\n\n@endglobals").unwrap();
1956        assert_eq!(
1957            nodes,
1958            vec![Node::Globals(vec![GlobalEntry {
1959                name: "title".to_string(),
1960                expr: "\"x\"".to_string(),
1961                persist: false,
1962            }])]
1963        );
1964    }
1965
1966    #[test]
1967    fn globals_assignment_with_equality_operator_in_expression() {
1968        let nodes = parse("@globals\nactive = state == \"open\"\n@endglobals").unwrap();
1969        assert_eq!(
1970            nodes,
1971            vec![Node::Globals(vec![GlobalEntry {
1972                name: "active".to_string(),
1973                expr: "state == \"open\"".to_string(),
1974                persist: false,
1975            }])]
1976        );
1977    }
1978
1979    #[test]
1980    fn globals_assignment_with_equals_sign_inside_string_literal() {
1981        let nodes = parse("@globals\nnote = \"a=b\"\n@endglobals").unwrap();
1982        assert_eq!(
1983            nodes,
1984            vec![Node::Globals(vec![GlobalEntry {
1985                name: "note".to_string(),
1986                expr: "\"a=b\"".to_string(),
1987                persist: false,
1988            }])]
1989        );
1990    }
1991
1992    #[test]
1993    fn parses_a_persist_prefixed_globals_entry() {
1994        let nodes = parse("@globals\npersist theme = \"dark\"\n@endglobals").unwrap();
1995        assert_eq!(
1996            nodes,
1997            vec![Node::Globals(vec![GlobalEntry {
1998                name: "theme".to_string(),
1999                expr: "\"dark\"".to_string(),
2000                persist: true,
2001            }])]
2002        );
2003    }
2004
2005    #[test]
2006    fn persist_and_plain_entries_can_be_mixed_in_the_same_block() {
2007        let nodes = parse(
2008            "@globals\n\
2009             menuvisible = \"false\"\n\
2010             persist theme = \"dark\"\n\
2011             @endglobals",
2012        )
2013        .unwrap();
2014        assert_eq!(
2015            nodes,
2016            vec![Node::Globals(vec![
2017                GlobalEntry {
2018                    name: "menuvisible".to_string(),
2019                    expr: "\"false\"".to_string(),
2020                    persist: false,
2021                },
2022                GlobalEntry {
2023                    name: "theme".to_string(),
2024                    expr: "\"dark\"".to_string(),
2025                    persist: true,
2026                },
2027            ])]
2028        );
2029    }
2030
2031    #[test]
2032    fn persist_entrys_name_is_still_validated() {
2033        let err = parse("@globals\npersist not valid = \"x\"\n@endglobals").unwrap_err();
2034        assert!(
2035            err.to_string().contains("invalid variable name"),
2036            "message was: {err}"
2037        );
2038    }
2039
2040    #[test]
2041    fn malformed_globals_line_is_a_clear_error() {
2042        let err = parse("@globals\nnot an assignment\n@endglobals").unwrap_err();
2043        assert!(
2044            err.to_string().contains("expected `name = expression`"),
2045            "message was: {err}"
2046        );
2047    }
2048
2049    #[test]
2050    fn unterminated_globals_block_is_a_clear_error() {
2051        let err = parse("@globals\ntitle = \"x\"").unwrap_err();
2052        assert!(err.to_string().contains("unterminated @globals block"));
2053    }
2054
2055    #[test]
2056    fn globals_assignment_with_an_invalid_name_is_a_clear_error() {
2057        // A name containing a space (or any character `@global(name)`'s own
2058        // bare-identifier scanner wouldn't accept) must be rejected here
2059        // too - otherwise it silently registers under a key no
2060        // `@global(...)` placeholder could ever match, and the mismatch
2061        // would surface nowhere at all.
2062        let err = parse("@globals\nmy title = \"x\"\n@endglobals").unwrap_err();
2063        assert!(
2064            err.to_string().contains("invalid variable name"),
2065            "message was: {err}"
2066        );
2067    }
2068
2069    #[test]
2070    fn stray_endglobals_at_top_level_is_a_clear_error() {
2071        let err = parse("hi @endglobals there").unwrap_err();
2072        assert!(err.to_string().contains("no matching opening directive"));
2073    }
2074
2075    #[test]
2076    fn parses_wire_with_no_props() {
2077        let nodes = parse("@wire('search-box')").unwrap();
2078        assert_eq!(
2079            nodes,
2080            vec![Node::Wire {
2081                name: "search-box".to_string(),
2082                props: vec![],
2083            }]
2084        );
2085    }
2086
2087    #[test]
2088    fn parses_wire_with_props() {
2089        let nodes = parse(r#"@wire('search-box', { query: "", limit: 10 })"#).unwrap();
2090        assert_eq!(
2091            nodes,
2092            vec![Node::Wire {
2093                name: "search-box".to_string(),
2094                props: vec![
2095                    ("query".to_string(), "\"\"".to_string()),
2096                    ("limit".to_string(), "10".to_string()),
2097                ],
2098            }]
2099        );
2100    }
2101
2102    #[test]
2103    fn wire_props_handle_nested_parens_braces_brackets_and_strings() {
2104        let nodes = parse(
2105            r#"@wire('widget', { items: vec![1, 2], meta: Foo { a: 1 }, label: default_label("(x)") })"#,
2106        )
2107        .unwrap();
2108        assert_eq!(
2109            nodes,
2110            vec![Node::Wire {
2111                name: "widget".to_string(),
2112                props: vec![
2113                    ("items".to_string(), "vec![1, 2]".to_string()),
2114                    ("meta".to_string(), "Foo { a: 1 }".to_string()),
2115                    ("label".to_string(), r#"default_label("(x)")"#.to_string()),
2116                ],
2117            }]
2118        );
2119    }
2120
2121    #[test]
2122    fn wire_props_handle_a_trailing_comma() {
2123        let nodes = parse(r#"@wire('widget', { query: "x", })"#).unwrap();
2124        assert_eq!(
2125            nodes,
2126            vec![Node::Wire {
2127                name: "widget".to_string(),
2128                props: vec![("query".to_string(), "\"x\"".to_string())],
2129            }]
2130        );
2131    }
2132
2133    #[test]
2134    fn wire_with_empty_props_object() {
2135        let nodes = parse("@wire('widget', {})").unwrap();
2136        assert_eq!(
2137            nodes,
2138            vec![Node::Wire {
2139                name: "widget".to_string(),
2140                props: vec![],
2141            }]
2142        );
2143    }
2144
2145    #[test]
2146    fn missing_prop_colon_is_a_clear_error() {
2147        let err = parse("@wire('widget', { query })").unwrap_err();
2148        assert!(
2149            err.to_string().contains("expected `name: expression`"),
2150            "message was: {err}"
2151        );
2152    }
2153
2154    #[test]
2155    fn missing_prop_value_is_a_clear_error() {
2156        let err = parse("@wire('widget', { query: })").unwrap_err();
2157        assert!(
2158            err.to_string().contains("expected an expression after ':'"),
2159            "message was: {err}"
2160        );
2161    }
2162
2163    #[test]
2164    fn invalid_prop_name_is_a_clear_error() {
2165        let err = parse(r#"@wire('widget', { "query": "x" })"#).unwrap_err();
2166        assert!(
2167            err.to_string().contains("invalid prop name"),
2168            "message was: {err}"
2169        );
2170    }
2171
2172    #[test]
2173    fn unterminated_wire_props_is_a_clear_error() {
2174        let err = parse("@wire('widget', { query: \"x\"").unwrap_err();
2175        assert!(
2176            err.to_string().contains("unterminated '{' in @wire"),
2177            "message was: {err}"
2178        );
2179    }
2180
2181    #[test]
2182    fn missing_comma_or_close_paren_in_wire_is_a_clear_error() {
2183        let err = parse("@wire('widget' oops)").unwrap_err();
2184        assert!(
2185            err.to_string().contains("expected ',' or ')'"),
2186            "message was: {err}"
2187        );
2188    }
2189
2190    #[test]
2191    fn parses_larustscripts() {
2192        let nodes = parse("<body>@larustscripts</body>").unwrap();
2193        assert_eq!(
2194            nodes,
2195            vec![
2196                Node::Text("<body>".to_string()),
2197                Node::LarustScripts,
2198                Node::Text("</body>".to_string()),
2199            ]
2200        );
2201    }
2202
2203    #[test]
2204    fn parses_loadonce() {
2205        let nodes = parse("@loadonce<script></script>@endloadonce").unwrap();
2206        assert_eq!(
2207            nodes,
2208            vec![Node::LoadOnce(vec![Node::Text(
2209                "<script></script>".to_string()
2210            )])]
2211        );
2212    }
2213
2214    #[test]
2215    fn loadonce_can_contain_ordinary_directives() {
2216        let nodes = parse("@loadonce@if(ok)yes@endif@endloadonce").unwrap();
2217        assert_eq!(
2218            nodes,
2219            vec![Node::LoadOnce(vec![Node::If {
2220                cond: "ok".to_string(),
2221                then_branch: vec![Node::Text("yes".to_string())],
2222                else_branch: vec![],
2223            }])]
2224        );
2225    }
2226
2227    #[test]
2228    fn unclosed_loadonce_is_a_clear_error() {
2229        let err = parse("@loadonce<script></script>").unwrap_err();
2230        assert!(err.to_string().contains("unexpected end of template"));
2231    }
2232
2233    #[test]
2234    fn stray_endloadonce_at_top_level_is_a_clear_error() {
2235        let err = parse("hi @endloadonce there").unwrap_err();
2236        assert!(err.to_string().contains("no matching opening directive"));
2237    }
2238
2239    #[test]
2240    fn parses_resource_with_no_props_and_a_slot() {
2241        let nodes = parse("@resource('components.panel')<p>hi</p>@endresource").unwrap();
2242        assert_eq!(
2243            nodes,
2244            vec![Node::Resource {
2245                name: "components.panel".to_string(),
2246                props: vec![],
2247                slot: vec![Node::Text("<p>hi</p>".to_string())],
2248            }]
2249        );
2250    }
2251
2252    #[test]
2253    fn parses_resource_with_props_and_an_empty_slot() {
2254        let nodes =
2255            parse(r#"@resource('components.badge', { label: "New" })@endresource"#).unwrap();
2256        assert_eq!(
2257            nodes,
2258            vec![Node::Resource {
2259                name: "components.badge".to_string(),
2260                props: vec![("label".to_string(), "\"New\"".to_string())],
2261                slot: vec![],
2262            }]
2263        );
2264    }
2265
2266    #[test]
2267    fn resource_slot_can_contain_ordinary_directives() {
2268        let nodes = parse("@resource('components.panel')@if(ok)yes@endif@endresource").unwrap();
2269        assert_eq!(
2270            nodes,
2271            vec![Node::Resource {
2272                name: "components.panel".to_string(),
2273                props: vec![],
2274                slot: vec![Node::If {
2275                    cond: "ok".to_string(),
2276                    then_branch: vec![Node::Text("yes".to_string())],
2277                    else_branch: vec![],
2278                }],
2279            }]
2280        );
2281    }
2282
2283    #[test]
2284    fn unclosed_resource_is_a_clear_error() {
2285        let err = parse("@resource('components.panel')<p>hi</p>").unwrap_err();
2286        assert!(err.to_string().contains("unexpected end of template"));
2287    }
2288
2289    #[test]
2290    fn stray_endresource_at_top_level_is_a_clear_error() {
2291        let err = parse("hi @endresource there").unwrap_err();
2292        assert!(err.to_string().contains("no matching opening directive"));
2293    }
2294
2295    #[test]
2296    fn missing_comma_or_close_paren_in_resource_is_a_clear_error() {
2297        let err = parse("@resource('panel' oops)@endresource").unwrap_err();
2298        assert!(
2299            err.to_string().contains("expected ',' or ')'"),
2300            "message was: {err}"
2301        );
2302    }
2303
2304    #[test]
2305    fn parses_live_with_a_string_literal_channel() {
2306        let nodes = parse("@live('posts.count')<span>5</span>@endlive").unwrap();
2307        assert_eq!(
2308            nodes,
2309            vec![Node::Live {
2310                channel: "'posts.count'".to_string(),
2311                body: vec![Node::Text("<span>5</span>".to_string())],
2312            }]
2313        );
2314    }
2315
2316    #[test]
2317    fn live_channel_can_be_an_arbitrary_expression() {
2318        // Unlike `@wire`/`@resource`'s own `name` argument (a quoted
2319        // string only), `@live`'s channel is parsed the same way
2320        // `@if`/`@foreach` parse their own arguments - any expression, so
2321        // a channel can be scoped dynamically per-resource.
2322        let nodes = parse(r#"@live(format!("post.{}.comments", post.id))hi@endlive"#).unwrap();
2323        assert_eq!(
2324            nodes,
2325            vec![Node::Live {
2326                channel: r#"format!("post.{}.comments", post.id)"#.to_string(),
2327                body: vec![Node::Text("hi".to_string())],
2328            }]
2329        );
2330    }
2331
2332    #[test]
2333    fn live_body_can_contain_ordinary_directives() {
2334        let nodes = parse("@live('c')@if(ok)yes@endif@endlive").unwrap();
2335        assert_eq!(
2336            nodes,
2337            vec![Node::Live {
2338                channel: "'c'".to_string(),
2339                body: vec![Node::If {
2340                    cond: "ok".to_string(),
2341                    then_branch: vec![Node::Text("yes".to_string())],
2342                    else_branch: vec![],
2343                }],
2344            }]
2345        );
2346    }
2347
2348    #[test]
2349    fn unclosed_live_is_a_clear_error() {
2350        let err = parse("@live('c')<p>hi</p>").unwrap_err();
2351        assert!(err.to_string().contains("unexpected end of template"));
2352    }
2353
2354    #[test]
2355    fn stray_endlive_at_top_level_is_a_clear_error() {
2356        let err = parse("hi @endlive there").unwrap_err();
2357        assert!(err.to_string().contains("no matching opening directive"));
2358    }
2359
2360    #[test]
2361    fn unterminated_paren_in_live_channel_is_a_clear_error() {
2362        let err = parse("@live('c'").unwrap_err();
2363        assert!(err.to_string().contains("unterminated '(' in directive"));
2364    }
2365
2366    #[test]
2367    fn parses_can_with_no_else() {
2368        let nodes = parse("@can(Permission::EditPosts)<b>yes</b>@endcan").unwrap();
2369        assert_eq!(
2370            nodes,
2371            vec![Node::Can {
2372                permission: "Permission::EditPosts".to_string(),
2373                then_branch: vec![Node::Text("<b>yes</b>".to_string())],
2374                else_branch: vec![],
2375            }]
2376        );
2377    }
2378
2379    #[test]
2380    fn parses_can_with_an_else_branch() {
2381        let nodes = parse("@can(Permission::EditPosts)yes@else no@endcan").unwrap();
2382        assert_eq!(
2383            nodes,
2384            vec![Node::Can {
2385                permission: "Permission::EditPosts".to_string(),
2386                then_branch: vec![Node::Text("yes".to_string())],
2387                else_branch: vec![Node::Text(" no".to_string())],
2388            }]
2389        );
2390    }
2391
2392    #[test]
2393    fn parses_role_with_no_else() {
2394        let nodes = parse("@role(Role::Admin)<b>admin</b>@endrole").unwrap();
2395        assert_eq!(
2396            nodes,
2397            vec![Node::Role {
2398                role: "Role::Admin".to_string(),
2399                then_branch: vec![Node::Text("<b>admin</b>".to_string())],
2400                else_branch: vec![],
2401            }]
2402        );
2403    }
2404
2405    #[test]
2406    fn parses_role_with_an_else_branch() {
2407        let nodes = parse("@role(Role::Admin)yes@else no@endrole").unwrap();
2408        assert_eq!(
2409            nodes,
2410            vec![Node::Role {
2411                role: "Role::Admin".to_string(),
2412                then_branch: vec![Node::Text("yes".to_string())],
2413                else_branch: vec![Node::Text(" no".to_string())],
2414            }]
2415        );
2416    }
2417
2418    #[test]
2419    fn can_body_can_contain_ordinary_directives() {
2420        let nodes = parse("@can(Permission::EditPosts)@if(ok)yes@endif@endcan").unwrap();
2421        assert_eq!(
2422            nodes,
2423            vec![Node::Can {
2424                permission: "Permission::EditPosts".to_string(),
2425                then_branch: vec![Node::If {
2426                    cond: "ok".to_string(),
2427                    then_branch: vec![Node::Text("yes".to_string())],
2428                    else_branch: vec![],
2429                }],
2430                else_branch: vec![],
2431            }]
2432        );
2433    }
2434
2435    #[test]
2436    fn can_can_nest_inside_role() {
2437        let nodes =
2438            parse("@role(Role::Admin)@can(Permission::EditPosts)yes@endcan@endrole").unwrap();
2439        assert_eq!(
2440            nodes,
2441            vec![Node::Role {
2442                role: "Role::Admin".to_string(),
2443                then_branch: vec![Node::Can {
2444                    permission: "Permission::EditPosts".to_string(),
2445                    then_branch: vec![Node::Text("yes".to_string())],
2446                    else_branch: vec![],
2447                }],
2448                else_branch: vec![],
2449            }]
2450        );
2451    }
2452
2453    #[test]
2454    fn unclosed_can_is_a_clear_error() {
2455        let err = parse("@can(Permission::EditPosts)<p>hi</p>").unwrap_err();
2456        assert!(err.to_string().contains("unexpected end of template"));
2457    }
2458
2459    #[test]
2460    fn mismatched_can_endrole_is_a_clear_error() {
2461        let err = parse("@can(Permission::EditPosts)hi@endrole").unwrap_err();
2462        assert!(
2463            err.to_string().contains("expected @else or @endcan"),
2464            "message was: {err}"
2465        );
2466    }
2467
2468    #[test]
2469    fn stray_endcan_at_top_level_is_a_clear_error() {
2470        let err = parse("hi @endcan there").unwrap_err();
2471        assert!(err.to_string().contains("no matching opening directive"));
2472    }
2473
2474    #[test]
2475    fn stray_endrole_at_top_level_is_a_clear_error() {
2476        let err = parse("hi @endrole there").unwrap_err();
2477        assert!(err.to_string().contains("no matching opening directive"));
2478    }
2479
2480    #[test]
2481    fn parses_spa_with_a_body() {
2482        let nodes = parse("@spa hi @endspa").unwrap();
2483        assert_eq!(nodes, vec![Node::Spa(vec![Node::Text(" hi ".to_string())])]);
2484    }
2485
2486    #[test]
2487    fn spa_can_wrap_a_yield() {
2488        let nodes = parse("@spa @yield('content') @endspa").unwrap();
2489        assert_eq!(
2490            nodes,
2491            vec![Node::Spa(vec![
2492                Node::Text(" ".to_string()),
2493                Node::Yield("content".to_string()),
2494                Node::Text(" ".to_string()),
2495            ])]
2496        );
2497    }
2498
2499    #[test]
2500    fn unclosed_spa_is_a_clear_error() {
2501        let err = parse("@spa hi").unwrap_err();
2502        assert!(err.to_string().contains("unexpected end of template"));
2503    }
2504
2505    #[test]
2506    fn mismatched_spa_closer_is_a_clear_error() {
2507        let err = parse("@spa hi @endforeach").unwrap_err();
2508        assert!(
2509            err.to_string().contains("expected @endspa"),
2510            "message was: {err}"
2511        );
2512    }
2513
2514    #[test]
2515    fn stray_endspa_at_top_level_is_a_clear_error() {
2516        let err = parse("hi @endspa there").unwrap_err();
2517        assert!(err.to_string().contains("no matching opening directive"));
2518    }
2519
2520    #[test]
2521    fn resource_tag_syntax_produces_the_same_node_the_directive_syntax_does() {
2522        let tag = parse(r#"<resource:components.badge label="New" />"#).unwrap();
2523        let directive =
2524            parse(r#"@resource('components.badge', { label: "New" })@endresource"#).unwrap();
2525        assert_eq!(tag, directive);
2526    }
2527
2528    #[test]
2529    fn parses_resource_tag_self_closing_with_no_props() {
2530        let nodes = parse("<resource:components.panel/>").unwrap();
2531        assert_eq!(
2532            nodes,
2533            vec![Node::Resource {
2534                name: "components.panel".to_string(),
2535                props: vec![],
2536                slot: vec![],
2537            }]
2538        );
2539    }
2540
2541    #[test]
2542    fn parses_resource_tag_self_closing_with_literal_and_dynamic_props() {
2543        let nodes = parse(r#"<resource:components.badge label="New" :count="unread" />"#).unwrap();
2544        assert_eq!(
2545            nodes,
2546            vec![Node::Resource {
2547                name: "components.badge".to_string(),
2548                props: vec![
2549                    ("label".to_string(), "\"New\"".to_string()),
2550                    ("count".to_string(), "unread".to_string()),
2551                ],
2552                slot: vec![],
2553            }]
2554        );
2555    }
2556
2557    #[test]
2558    fn parses_resource_tag_block_form_with_a_slot() {
2559        let nodes =
2560            parse(r#"<resource:components.panel title="Hi"><p>hi</p></resource:components.panel>"#)
2561                .unwrap();
2562        assert_eq!(
2563            nodes,
2564            vec![Node::Resource {
2565                name: "components.panel".to_string(),
2566                props: vec![("title".to_string(), "\"Hi\"".to_string())],
2567                slot: vec![Node::Text("<p>hi</p>".to_string())],
2568            }]
2569        );
2570    }
2571
2572    #[test]
2573    fn resource_tag_literal_attribute_with_a_double_quote_is_escaped() {
2574        let nodes = parse(r#"<resource:components.badge label='Say "hi"' />"#).unwrap();
2575        assert_eq!(
2576            nodes,
2577            vec![Node::Resource {
2578                name: "components.badge".to_string(),
2579                props: vec![("label".to_string(), "\"Say \\\"hi\\\"\"".to_string())],
2580                slot: vec![],
2581            }]
2582        );
2583    }
2584
2585    #[test]
2586    fn resource_tag_slot_can_contain_ordinary_directives() {
2587        let nodes =
2588            parse("<resource:components.panel>@if(ok)yes@endif</resource:components.panel>")
2589                .unwrap();
2590        assert_eq!(
2591            nodes,
2592            vec![Node::Resource {
2593                name: "components.panel".to_string(),
2594                props: vec![],
2595                slot: vec![Node::If {
2596                    cond: "ok".to_string(),
2597                    then_branch: vec![Node::Text("yes".to_string())],
2598                    else_branch: vec![],
2599                }],
2600            }]
2601        );
2602    }
2603
2604    #[test]
2605    fn nested_resource_tags_parse_correctly() {
2606        let nodes = parse("<resource:a><resource:b>x</resource:b></resource:a>").unwrap();
2607        assert_eq!(
2608            nodes,
2609            vec![Node::Resource {
2610                name: "a".to_string(),
2611                props: vec![],
2612                slot: vec![Node::Resource {
2613                    name: "b".to_string(),
2614                    props: vec![],
2615                    slot: vec![Node::Text("x".to_string())],
2616                }],
2617            }]
2618        );
2619    }
2620
2621    #[test]
2622    fn resource_tag_name_mismatch_between_open_and_close_is_a_clear_error() {
2623        let err = parse("<resource:a>hi</resource:b>").unwrap_err();
2624        let message = err.to_string();
2625        assert!(
2626            message.contains("expected </resource:a>"),
2627            "message was: {message}"
2628        );
2629        assert!(
2630            message.contains("found </resource:b>"),
2631            "message was: {message}"
2632        );
2633    }
2634
2635    #[test]
2636    fn unclosed_resource_tag_is_a_clear_error() {
2637        let err = parse("<resource:a><p>hi</p>").unwrap_err();
2638        assert!(err.to_string().contains("unexpected end of template"));
2639    }
2640
2641    #[test]
2642    fn stray_closing_resource_tag_at_top_level_is_a_clear_error() {
2643        let err = parse("hi </resource:a> there").unwrap_err();
2644        let message = err.to_string();
2645        assert!(
2646            message.contains("no matching <resource:a>"),
2647            "message was: {message}"
2648        );
2649    }
2650
2651    #[test]
2652    fn resource_tag_with_no_name_is_a_clear_error() {
2653        let err = parse("<resource:/>").unwrap_err();
2654        assert!(
2655            err.to_string()
2656                .contains("expected a name after '<resource:'"),
2657            "message was: {err}"
2658        );
2659    }
2660
2661    #[test]
2662    fn wire_tag_syntax_produces_the_same_node_the_directive_syntax_does() {
2663        let tag = parse(r#"<wire:search-box query="" :limit="10" />"#).unwrap();
2664        let directive = parse(r#"@wire('search-box', { query: "", limit: 10 })"#).unwrap();
2665        assert_eq!(tag, directive);
2666    }
2667
2668    #[test]
2669    fn parses_wire_tag_with_no_props() {
2670        let nodes = parse("<wire:search-box/>").unwrap();
2671        assert_eq!(
2672            nodes,
2673            vec![Node::Wire {
2674                name: "search-box".to_string(),
2675                props: vec![],
2676            }]
2677        );
2678    }
2679
2680    #[test]
2681    fn parses_wire_tag_with_literal_and_dynamic_props() {
2682        let nodes = parse(r#"<wire:post-form title="Untitled" :post_id="post.id" />"#).unwrap();
2683        assert_eq!(
2684            nodes,
2685            vec![Node::Wire {
2686                name: "post-form".to_string(),
2687                props: vec![
2688                    ("title".to_string(), "\"Untitled\"".to_string()),
2689                    ("post_id".to_string(), "post.id".to_string()),
2690                ],
2691            }]
2692        );
2693    }
2694
2695    #[test]
2696    fn wire_tag_without_self_close_is_a_clear_error() {
2697        let err = parse("<wire:search-box>").unwrap_err();
2698        assert!(
2699            err.to_string().contains("must be self-closing"),
2700            "message was: {err}"
2701        );
2702    }
2703
2704    #[test]
2705    fn wire_tag_with_no_name_is_a_clear_error() {
2706        let err = parse("<wire:/>").unwrap_err();
2707        assert!(
2708            err.to_string().contains("expected a name after '<wire:'"),
2709            "message was: {err}"
2710        );
2711    }
2712
2713    #[test]
2714    fn wire_tag_unterminated_is_a_clear_error() {
2715        let err = parse("<wire:search-box").unwrap_err();
2716        assert!(
2717            err.to_string()
2718                .contains("expected '/>' to close <wire:search-box>"),
2719            "message was: {err}"
2720        );
2721    }
2722
2723    #[test]
2724    fn parses_trusted_rust_code_block() {
2725        assert_eq!(
2726            parse("@code let label = \"Hello\"; @endcode{{ label }}").unwrap(),
2727            vec![
2728                Node::Code(" let label = \"Hello\"; ".to_string()),
2729                Node::Interpolate {
2730                    expr: "label".to_string(),
2731                    escape: true,
2732                },
2733            ]
2734        );
2735    }
2736
2737    #[test]
2738    fn parses_a_code_block_immediately_followed_by_a_raw_interpolation() {
2739        // A `@code` block immediately followed - no whitespace - by a
2740        // raw (unescaped) interpolation splicing its own result in,
2741        // rather than the escaped `{{ }}` form `parses_trusted_rust_code_
2742        // block` above already covers.
2743        assert_eq!(
2744            parse(
2745                "@code let __vitex_tags = larust_support::vitex::tags(&[\"a\"]); @endcode{!! __vitex_tags !!}"
2746            )
2747            .unwrap(),
2748            vec![
2749                Node::Code(
2750                    " let __vitex_tags = larust_support::vitex::tags(&[\"a\"]); ".to_string()
2751                ),
2752                Node::Interpolate {
2753                    expr: "__vitex_tags".to_string(),
2754                    escape: false,
2755                },
2756            ]
2757        );
2758    }
2759
2760    #[test]
2761    fn parses_vitex_with_multiple_entries() {
2762        // Real source: `components/layouts/app.blade.xr`'s translated
2763        // `@vite(['resources/css/app.min.css', 'resources/js/app.min.js'])`
2764        // - `@vitex` mirrors that exact array-of-paths syntax.
2765        assert_eq!(
2766            parse("@vitex(['resources/css/app.min.css', 'resources/js/app.min.js'])").unwrap(),
2767            vec![Node::Vitex(vec![
2768                "resources/css/app.min.css".to_string(),
2769                "resources/js/app.min.js".to_string(),
2770            ])]
2771        );
2772    }
2773
2774    #[test]
2775    fn parses_js_with_a_simple_expression() {
2776        let nodes = parse("<script>const post = @js(post);</script>").unwrap();
2777        assert_eq!(
2778            nodes,
2779            vec![
2780                Node::Text("<script>const post = ".to_string()),
2781                Node::Js("post".to_string()),
2782                Node::Text(";</script>".to_string()),
2783            ]
2784        );
2785    }
2786
2787    #[test]
2788    fn js_expression_can_be_arbitrary_like_live_channel() {
2789        // `@js(...)` takes any expression, the same way `@live(...)`'s
2790        // channel argument does (`parse_paren_expr`, not the quoted-string-
2791        // only argument `@wire`/`@resource` take).
2792        let nodes = parse(r#"@js(serde_json::json!({"id": post.id}))"#).unwrap();
2793        assert_eq!(
2794            nodes,
2795            vec![Node::Js(
2796                r#"serde_json::json!({"id": post.id})"#.to_string()
2797            )]
2798        );
2799    }
2800
2801    #[test]
2802    fn unterminated_paren_in_js_expression_is_a_clear_error() {
2803        let err = parse("@js(post").unwrap_err();
2804        assert!(err.to_string().contains("unterminated '(' in directive"));
2805    }
2806
2807    #[test]
2808    fn parses_vitex_with_a_single_double_quoted_entry() {
2809        assert_eq!(
2810            parse("@vitex([\"resources/js/app.js\"])").unwrap(),
2811            vec![Node::Vitex(vec!["resources/js/app.js".to_string()])]
2812        );
2813    }
2814
2815    #[test]
2816    fn parses_vitex_tolerating_a_trailing_comma() {
2817        assert_eq!(
2818            parse("@vitex(['a', 'b',])").unwrap(),
2819            vec![Node::Vitex(vec!["a".to_string(), "b".to_string()])]
2820        );
2821    }
2822
2823    #[test]
2824    fn rejects_vitex_with_an_empty_array() {
2825        let err = parse("@vitex([])").unwrap_err();
2826        assert!(
2827            err.to_string().contains("at least one entry"),
2828            "message was: {err}"
2829        );
2830    }
2831
2832    #[test]
2833    fn rejects_vitex_missing_the_array_brackets() {
2834        let err = parse("@vitex('resources/js/app.js')").unwrap_err();
2835        assert!(err.to_string().contains('['), "message was: {err}");
2836    }
2837}