Skip to main content

larust_view/
resolve.rs

1use crate::ast::{GlobalEntry, Node};
2use crate::error::ParseError;
3use std::collections::{HashMap, HashSet};
4
5/// `resolve_with_context`'s own `@push`/`@globals` collections, gathered
6/// alongside the resolved node list - see that function's own doc comment
7/// for why callers need both, not just the flat node list `resolve`
8/// alone returns.
9pub type PushesAndGlobals = (HashMap<String, Vec<Node>>, HashMap<String, GlobalEntry>);
10
11/// Resolves `@extends`/`@section`/`@yield`/`@push`/`@stack` composition
12/// into a single, flat node list ready for codegen.
13///
14/// `load` fetches and parses another template by name (e.g. `"layouts.app"`) -
15/// called recursively, so a layout chain (`page` extends `app` extends
16/// `base`) resolves correctly, not just one level.
17pub fn resolve(
18    nodes: Vec<Node>,
19    load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
20) -> Result<Vec<Node>, ParseError> {
21    let (nodes, _) = resolve_with_context(nodes, load)?;
22    Ok(nodes)
23}
24
25/// Same as [`resolve`], but also returns the whole-tree `@push`/`@globals`
26/// collections it gathered along the way - needed by `larust-macros`,
27/// which loads and codegens each `<resource:...>` tag's own named template
28/// *separately* from this call (see `Node::Resource`'s own doc comment in
29/// `ast.rs`), and must apply [`substitute_stacks`]/[`substitute_globals`]
30/// to that freshly-loaded content itself using these same maps - otherwise
31/// a `@push`/`@stack` pair split across a resource-tag boundary (the
32/// single most common shape in practice: a shared `components.layouts.app`
33/// providing `@stack('head')`, included via `<resource:...>` from every
34/// page, with pages pushing their own per-page `<link>`/`<meta>` tags into
35/// it) would never connect - `@stack('head')` sitting inside that
36/// separately-loaded layout file is invisible to *this* call's own
37/// `substitute_stacks`, which only ever walks the nodes passed in as
38/// `nodes` here.
39pub fn resolve_with_context(
40    nodes: Vec<Node>,
41    load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
42) -> Result<(Vec<Node>, PushesAndGlobals), ParseError> {
43    let mut pushes: HashMap<String, Vec<Node>> = HashMap::new();
44    let mut globals: HashMap<String, GlobalEntry> = HashMap::new();
45    let resolved = resolve_inner(nodes, load, &mut HashSet::new(), &mut pushes, &mut globals)?;
46    let resolved = substitute_stacks(resolved, &pushes);
47    let resolved = substitute_globals(resolved, &globals);
48    Ok((resolved, (pushes, globals)))
49}
50
51fn resolve_inner(
52    nodes: Vec<Node>,
53    load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
54    seen: &mut HashSet<String>,
55    pushes: &mut HashMap<String, Vec<Node>>,
56    globals: &mut HashMap<String, GlobalEntry>,
57) -> Result<Vec<Node>, ParseError> {
58    // Collected from *every* level of the chain, before anything else -
59    // `@push`/`@stack` are resolved as a wholly separate pass from
60    // `@section`/`@yield` (see `resolve()`), specifically so a `@stack` in
61    // the base-most layout can see pushes contributed by *every* level of
62    // the chain (child, its parent, its parent's parent, ...), not just
63    // whichever single level's `substitute_yields` call happened to reach
64    // it first. Order: child-most level's own pushes first (this call
65    // runs before recursing into the parent below), each level's own
66    // multiple `@push`es to the same name in their own source order.
67    collect_pushes(&nodes, pushes, load)?;
68    // Same whole-chain-first shape as pushes, for the same reason - plus
69    // one more: `@section`/`@yield`'s per-level eager `substitute_yields`
70    // would let an indifferent *middle* layout blank a `@global` before a
71    // leaf page's `@globals` ever reaches it (see `docs/MACROS.md`).
72    // `collect_globals` (child-most first, first-write-wins in `globals`)
73    // gives the page precedence over any ancestor layout setting the same
74    // name, and lets it reach through layouts that don't touch that name
75    // at all.
76    collect_globals(&nodes, globals, load)?;
77
78    let extends_name = nodes.iter().find_map(|n| match n {
79        Node::Extends(name) => Some(name.clone()),
80        _ => None,
81    });
82
83    let Some(parent_name) = extends_name else {
84        return Ok(nodes);
85    };
86
87    // Without this, a template that (directly or via a longer chain)
88    // extends itself recurses without bound - a stack overflow inside
89    // rustc during macro expansion, not a clean diagnostic.
90    if !seen.insert(parent_name.clone()) {
91        return Err(ParseError::new(format!(
92            "cycle detected in @extends chain (revisited `{parent_name}`)"
93        )));
94    }
95
96    let mut sections = HashMap::new();
97    for node in nodes {
98        if let Node::Section { name, body } = node {
99            sections.insert(name, body);
100        }
101    }
102
103    let parent_nodes = load(&parent_name)?;
104    let resolved_parent = resolve_inner(parent_nodes, load, seen, pushes, globals)?;
105    Ok(substitute_yields(resolved_parent, &sections))
106}
107
108/// Recursively finds every `@push` in `nodes` (including inside
109/// `@if`/`@foreach`/`@section` bodies, and inside another `@push`'s own
110/// body) and appends its body to `pushes` under that push's name -
111/// accumulating, not overwriting, since that's the whole point of a stack
112/// versus a section.
113///
114/// Rejects a `@push` found inside a `@foreach`: `@push`/`@stack` here are
115/// resolved once, statically, at macro-expansion time - there's no
116/// per-iteration runtime step the way Laravel's own imperative,
117/// output-buffered Blade compiler has. A push inside a loop would either
118/// silently render its content exactly once instead of once per item, or
119/// (if it references the loop variable at all) fail to compile with a
120/// confusing "cannot find value" error pointing at generated code, not the
121/// template. Both are worse than refusing it outright with a clear reason.
122///
123/// `Node::Resource { name, slot, .. }` recurses into *both* `slot` (the
124/// caller-side content captured between `<resource:name>...</resource:name>`,
125/// already part of `nodes`) *and* `load(name)` - the resource's own named
126/// template, loaded fresh here purely to scan it for `@push`. That second
127/// load is necessary (not merely "for consistency"): the resource's own
128/// body is never otherwise part of any `resolve()` call's `nodes` - it's
129/// loaded a second time, independently, by `larust-macros` at codegen time
130/// for the actual rendering - so this is the *only* place a `@push` sitting
131/// inside a resource file (rather than at its call site) is ever seen by
132/// this collection pass. Naturally recursive for a resource file that
133/// itself includes further `<resource:...>` tags.
134fn collect_pushes(
135    nodes: &[Node],
136    pushes: &mut HashMap<String, Vec<Node>>,
137    load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
138) -> Result<(), ParseError> {
139    for node in nodes {
140        match node {
141            Node::Push { name, body } => {
142                pushes.entry(name.clone()).or_default().extend(body.clone());
143                collect_pushes(body, pushes, load)?;
144            }
145            Node::If {
146                then_branch,
147                else_branch,
148                ..
149            }
150            | Node::Can {
151                then_branch,
152                else_branch,
153                ..
154            }
155            | Node::Role {
156                then_branch,
157                else_branch,
158                ..
159            } => {
160                collect_pushes(then_branch, pushes, load)?;
161                collect_pushes(else_branch, pushes, load)?;
162            }
163            Node::Foreach { body, .. } => {
164                if contains_push(body) {
165                    return Err(ParseError::new(
166                        "`@push` inside `@foreach` isn't supported - pushed content is \
167                         resolved once at compile time, not once per loop iteration, so a \
168                         per-item push would silently render the wrong thing. Build the \
169                         string yourself inside the loop instead.",
170                    ));
171                }
172                collect_pushes(body, pushes, load)?;
173            }
174            Node::Section { body, .. }
175            | Node::LoadOnce(body)
176            | Node::Live { body, .. }
177            | Node::Spa(body) => collect_pushes(body, pushes, load)?,
178            Node::Resource { name, slot, .. } => {
179                collect_pushes(slot, pushes, load)?;
180                let resource_nodes = load(name)?;
181                collect_pushes(&resource_nodes, pushes, load)?;
182            }
183            _ => {}
184        }
185    }
186    Ok(())
187}
188
189/// Whether `@push` appears anywhere in `nodes`, including nested inside
190/// `@if`/`@foreach`/`@section`/another `@push` - used only to produce
191/// `collect_pushes`'s "`@push` inside `@foreach`" error eagerly, before
192/// walking (and partially registering) the offending subtree.
193fn contains_push(nodes: &[Node]) -> bool {
194    nodes.iter().any(|n| match n {
195        Node::Push { .. } => true,
196        Node::If {
197            then_branch,
198            else_branch,
199            ..
200        }
201        | Node::Can {
202            then_branch,
203            else_branch,
204            ..
205        }
206        | Node::Role {
207            then_branch,
208            else_branch,
209            ..
210        } => contains_push(then_branch) || contains_push(else_branch),
211        Node::Foreach { body, .. }
212        | Node::Section { body, .. }
213        | Node::LoadOnce(body)
214        | Node::Resource { slot: body, .. }
215        | Node::Live { body, .. }
216        | Node::Spa(body) => contains_push(body),
217        _ => false,
218    })
219}
220
221/// Recursively finds every `@globals` block in `nodes` (including inside
222/// `@foreach`/`@section`/`@push` bodies - but **not** `@if`/`@elseif`/
223/// `@else`, which is a compile error; see the `Node::If` arm below) and
224/// merges its `name = expr` entries into `globals`.
225///
226/// Two-phase, deliberately: first walk `nodes` into a *local* map, where a
227/// later assignment to the same name overwrites an earlier one within this
228/// same walk (ordinary sequential-assignment shadowing, like `let x = 1;
229/// let x = 2;`). Then merge that local map into the shared `globals` map
230/// via `entry(...).or_insert(...)` - only if the name is absent - so a
231/// value already collected from a more child-ward level (this function
232/// runs child-most-first, see `resolve_inner`) is never overwritten by an
233/// ancestor layout setting the same name. That's what makes a page's
234/// `@globals` win over a layout's `@globals` for the same name, not the
235/// other way around.
236fn collect_globals(
237    nodes: &[Node],
238    globals: &mut HashMap<String, GlobalEntry>,
239    load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
240) -> Result<(), ParseError> {
241    let mut local = HashMap::new();
242    collect_globals_into(nodes, &mut local, load)?;
243    for (name, entry) in local {
244        globals.entry(name).or_insert(entry);
245    }
246    Ok(())
247}
248
249/// See `collect_pushes`'s own doc comment on the `Node::Resource` arm -
250/// same reasoning applies here: a resource's own named template is loaded
251/// a second time, independently of its call site's `slot`, purely to scan
252/// it for `@globals` that would otherwise never be seen by any `resolve()`
253/// call at all.
254fn collect_globals_into(
255    nodes: &[Node],
256    local: &mut HashMap<String, GlobalEntry>,
257    load: &mut impl FnMut(&str) -> Result<Vec<Node>, ParseError>,
258) -> Result<(), ParseError> {
259    for node in nodes {
260        match node {
261            Node::Globals(entries) => {
262                for entry in entries {
263                    local.insert(entry.name.clone(), entry.clone());
264                }
265            }
266            // Both branches would otherwise be walked unconditionally -
267            // `@if`'s runtime condition has no bearing on this compile-time
268            // collection pass at all, so whichever branch happens to be
269            // visited last would silently win regardless of which branch
270            // the condition actually selects at render time. Same
271            // "resolved once, can't express per-branch semantics" reasoning
272            // as the `@foreach` rejection below, just for a different
273            // directive. If neither branch has a `@globals` at all, there's
274            // nothing to collect and nothing to reject - falls through to
275            // the catch-all `_ => {}` below.
276            Node::If {
277                then_branch,
278                else_branch,
279                ..
280            } if contains_globals(then_branch) || contains_globals(else_branch) => {
281                return Err(ParseError::new(
282                    "`@globals` inside `@if`/`@elseif`/`@else` isn't supported - global \
283                     overrides are resolved once at compile time, from every branch \
284                     unconditionally, not based on which branch the condition actually \
285                     selects at render time, so the result would silently ignore the \
286                     condition. Set the global unconditionally, or compute the value with a \
287                     conditional expression instead (e.g. \
288                     `title = if some_cond { \"A\" } else { \"B\" }`).",
289                ));
290            }
291            Node::Can {
292                then_branch,
293                else_branch,
294                ..
295            } if contains_globals(then_branch) || contains_globals(else_branch) => {
296                return Err(ParseError::new(
297                    "`@globals` inside `@can`/`@else` isn't supported - same reason as inside \
298                     `@if`: global overrides are resolved once at compile time, from every \
299                     branch unconditionally, not based on whether the current user actually \
300                     has the permission at render time.",
301                ));
302            }
303            Node::Role {
304                then_branch,
305                else_branch,
306                ..
307            } if contains_globals(then_branch) || contains_globals(else_branch) => {
308                return Err(ParseError::new(
309                    "`@globals` inside `@role`/`@else` isn't supported - same reason as \
310                     inside `@if`: global overrides are resolved once at compile time, from \
311                     every branch unconditionally, not based on whether the current user \
312                     actually has the role at render time.",
313                ));
314            }
315            Node::Foreach { body, .. } => {
316                if contains_globals(body) {
317                    return Err(ParseError::new(
318                        "`@globals` inside `@foreach` isn't supported - global overrides are \
319                         resolved once at compile time, not once per loop iteration, so a \
320                         per-item value has no coherent meaning.",
321                    ));
322                }
323                collect_globals_into(body, local, load)?;
324            }
325            Node::Section { body, .. }
326            | Node::Push { body, .. }
327            | Node::LoadOnce(body)
328            | Node::Live { body, .. }
329            | Node::Spa(body) => {
330                collect_globals_into(body, local, load)?;
331            }
332            Node::Resource { name, slot, .. } => {
333                collect_globals_into(slot, local, load)?;
334                let resource_nodes = load(name)?;
335                collect_globals_into(&resource_nodes, local, load)?;
336            }
337            _ => {}
338        }
339    }
340    Ok(())
341}
342
343/// Whether `@globals` appears anywhere in `nodes`, including nested inside
344/// `@if`/`@foreach`/`@section`/`@push` - used to produce `collect_globals`'s
345/// "`@globals` inside `@foreach`" and "`@globals` inside `@if`" errors
346/// eagerly, before walking (and, for `@foreach`, partially registering) the
347/// offending subtree.
348fn contains_globals(nodes: &[Node]) -> bool {
349    nodes.iter().any(|n| match n {
350        Node::Globals(_) => true,
351        Node::If {
352            then_branch,
353            else_branch,
354            ..
355        }
356        | Node::Can {
357            then_branch,
358            else_branch,
359            ..
360        }
361        | Node::Role {
362            then_branch,
363            else_branch,
364            ..
365        } => contains_globals(then_branch) || contains_globals(else_branch),
366        Node::Foreach { body, .. }
367        | Node::Section { body, .. }
368        | Node::Push { body, .. }
369        | Node::LoadOnce(body)
370        | Node::Resource { slot: body, .. }
371        | Node::Live { body, .. }
372        | Node::Spa(body) => contains_globals(body),
373        _ => false,
374    })
375}
376
377/// Mirrors `substitute_stacks` below, but for `@global`: walks the fully
378/// resolved tree, replacing `Node::Global { name, fallback }` with a real
379/// `Node::Interpolate` sourced from whichever `@globals` block (anywhere in
380/// the chain) provided that name, falling back to `fallback` if none did,
381/// or nothing if there's no fallback either - same "unset becomes empty"
382/// convention as an unset `@stack`. A `persist`-flagged entry substitutes
383/// to `Node::PersistGlobal` instead - its value isn't known until request
384/// time, so it can't collapse to a literal `Interpolate` the way every
385/// other entry does; see that node's own doc comment.
386pub fn substitute_globals(nodes: Vec<Node>, globals: &HashMap<String, GlobalEntry>) -> Vec<Node> {
387    nodes
388        .into_iter()
389        .flat_map(|node| -> Vec<Node> {
390            match node {
391                Node::Global { name, fallback } => match globals.get(&name) {
392                    Some(entry) if entry.persist => vec![Node::PersistGlobal {
393                        cookie_name: name,
394                        fallback_expr: entry.expr.clone(),
395                    }],
396                    Some(entry) => vec![Node::Interpolate {
397                        expr: entry.expr.clone(),
398                        escape: true,
399                    }],
400                    None => match fallback {
401                        Some(expr) => vec![Node::Interpolate { expr, escape: true }],
402                        None => vec![],
403                    },
404                },
405                Node::If {
406                    cond,
407                    then_branch,
408                    else_branch,
409                } => vec![Node::If {
410                    cond,
411                    then_branch: substitute_globals(then_branch, globals),
412                    else_branch: substitute_globals(else_branch, globals),
413                }],
414                Node::Can {
415                    permission,
416                    then_branch,
417                    else_branch,
418                } => vec![Node::Can {
419                    permission,
420                    then_branch: substitute_globals(then_branch, globals),
421                    else_branch: substitute_globals(else_branch, globals),
422                }],
423                Node::Role {
424                    role,
425                    then_branch,
426                    else_branch,
427                } => vec![Node::Role {
428                    role,
429                    then_branch: substitute_globals(then_branch, globals),
430                    else_branch: substitute_globals(else_branch, globals),
431                }],
432                Node::Foreach {
433                    binding,
434                    iter,
435                    body,
436                } => vec![Node::Foreach {
437                    binding,
438                    iter,
439                    body: substitute_globals(body, globals),
440                }],
441                Node::Section { name, body } => vec![Node::Section {
442                    name,
443                    body: substitute_globals(body, globals),
444                }],
445                Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_globals(body, globals))],
446                Node::Spa(body) => vec![Node::Spa(substitute_globals(body, globals))],
447                Node::Resource { name, props, slot } => vec![Node::Resource {
448                    name,
449                    props,
450                    slot: substitute_globals(slot, globals),
451                }],
452                Node::Live { channel, body } => vec![Node::Live {
453                    channel,
454                    body: substitute_globals(body, globals),
455                }],
456                other => vec![other],
457            }
458        })
459        .collect()
460}
461
462/// Mirrors `substitute_yields` below, but for `@stack` - kept as a
463/// separate pass (run once, after the entire `@extends` chain is already
464/// section/yield-resolved) rather than folded into `substitute_yields`
465/// itself, since `pushes` needs contributions from *every* level of the
466/// chain before any `@stack` can be substituted correctly (see
467/// `resolve_inner`'s doc comment on `collect_pushes`).
468///
469/// When `pushes` has **no** compile-time-known content for a given stack
470/// name, the `Node::Stack` node is left behind (instead of being replaced
471/// by nothing) as a runtime drain point for `larust_view::push_registry` -
472/// see that module's own doc comment and `docs/GOTCHAS.md`'s `@push`/
473/// `@stack` entry. This is what lets content pushed from a boundary this
474/// compile-time-only pass can't see across (a `<wire:...>` mount, or a
475/// separate `view!(...)` call glued in via `.into_html()`) still reach a
476/// `@stack` that has no *local* pushes of its own - the common real shape
477/// (a layout's own `@stack('head')` exists purely to catch outside
478/// contributions, not to hold any pushes it makes itself).
479///
480/// Deliberately scoped to the empty case only: a stack with *any*
481/// same-tree static content is left completely untouched (no runtime
482/// marker at all), so today's already-working compile-time-only behavior
483/// for that name is unaffected - mixing a local static push with a
484/// cross-boundary runtime one under the identical stack name in the same
485/// tree isn't supported (a documented, accepted limitation, not a bug).
486pub fn substitute_stacks(nodes: Vec<Node>, pushes: &HashMap<String, Vec<Node>>) -> Vec<Node> {
487    nodes
488        .into_iter()
489        .flat_map(|node| -> Vec<Node> {
490            match node {
491                Node::Stack(name) => {
492                    let spliced = pushes.get(&name).cloned().unwrap_or_default();
493                    if spliced.is_empty() {
494                        vec![Node::Stack(name)]
495                    } else {
496                        spliced
497                    }
498                }
499                Node::If {
500                    cond,
501                    then_branch,
502                    else_branch,
503                } => vec![Node::If {
504                    cond,
505                    then_branch: substitute_stacks(then_branch, pushes),
506                    else_branch: substitute_stacks(else_branch, pushes),
507                }],
508                Node::Can {
509                    permission,
510                    then_branch,
511                    else_branch,
512                } => vec![Node::Can {
513                    permission,
514                    then_branch: substitute_stacks(then_branch, pushes),
515                    else_branch: substitute_stacks(else_branch, pushes),
516                }],
517                Node::Role {
518                    role,
519                    then_branch,
520                    else_branch,
521                } => vec![Node::Role {
522                    role,
523                    then_branch: substitute_stacks(then_branch, pushes),
524                    else_branch: substitute_stacks(else_branch, pushes),
525                }],
526                Node::Foreach {
527                    binding,
528                    iter,
529                    body,
530                } => vec![Node::Foreach {
531                    binding,
532                    iter,
533                    body: substitute_stacks(body, pushes),
534                }],
535                Node::Section { name, body } => vec![Node::Section {
536                    name,
537                    body: substitute_stacks(body, pushes),
538                }],
539                Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_stacks(body, pushes))],
540                Node::Spa(body) => vec![Node::Spa(substitute_stacks(body, pushes))],
541                Node::Resource { name, props, slot } => vec![Node::Resource {
542                    name,
543                    props,
544                    slot: substitute_stacks(slot, pushes),
545                }],
546                Node::Live { channel, body } => vec![Node::Live {
547                    channel,
548                    body: substitute_stacks(body, pushes),
549                }],
550                other => vec![other],
551            }
552        })
553        .collect()
554}
555
556fn substitute_yields(nodes: Vec<Node>, sections: &HashMap<String, Vec<Node>>) -> Vec<Node> {
557    nodes
558        .into_iter()
559        .flat_map(|node| -> Vec<Node> {
560            match node {
561                Node::Yield(name) => sections.get(&name).cloned().unwrap_or_default(),
562                Node::If {
563                    cond,
564                    then_branch,
565                    else_branch,
566                } => vec![Node::If {
567                    cond,
568                    then_branch: substitute_yields(then_branch, sections),
569                    else_branch: substitute_yields(else_branch, sections),
570                }],
571                Node::Can {
572                    permission,
573                    then_branch,
574                    else_branch,
575                } => vec![Node::Can {
576                    permission,
577                    then_branch: substitute_yields(then_branch, sections),
578                    else_branch: substitute_yields(else_branch, sections),
579                }],
580                Node::Role {
581                    role,
582                    then_branch,
583                    else_branch,
584                } => vec![Node::Role {
585                    role,
586                    then_branch: substitute_yields(then_branch, sections),
587                    else_branch: substitute_yields(else_branch, sections),
588                }],
589                Node::Foreach {
590                    binding,
591                    iter,
592                    body,
593                } => vec![Node::Foreach {
594                    binding,
595                    iter,
596                    body: substitute_yields(body, sections),
597                }],
598                Node::Section { name, body } => vec![Node::Section {
599                    name,
600                    body: substitute_yields(body, sections),
601                }],
602                Node::LoadOnce(body) => vec![Node::LoadOnce(substitute_yields(body, sections))],
603                Node::Spa(body) => vec![Node::Spa(substitute_yields(body, sections))],
604                Node::Resource { name, props, slot } => vec![Node::Resource {
605                    name,
606                    props,
607                    slot: substitute_yields(slot, sections),
608                }],
609                Node::Live { channel, body } => vec![Node::Live {
610                    channel,
611                    body: substitute_yields(body, sections),
612                }],
613                other => vec![other],
614            }
615        })
616        .collect()
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use crate::parser::parse;
623
624    #[test]
625    fn substitutes_yield_with_matching_section() {
626        let layout = parse("<body>@yield('content')</body>").unwrap();
627        let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
628
629        let resolved = resolve(child, &mut |name| {
630            assert_eq!(name, "layout");
631            Ok(layout.clone())
632        })
633        .unwrap();
634
635        assert_eq!(
636            resolved,
637            vec![
638                Node::Text("<body>".to_string()),
639                Node::Text("hi".to_string()),
640                Node::Text("</body>".to_string()),
641            ]
642        );
643    }
644
645    #[test]
646    fn yield_with_no_matching_section_becomes_empty() {
647        let layout = parse("<body>@yield('content')</body>").unwrap();
648        let child = parse("@extends('layout')").unwrap();
649
650        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
651
652        assert_eq!(
653            resolved,
654            vec![
655                Node::Text("<body>".to_string()),
656                Node::Text("</body>".to_string()),
657            ]
658        );
659    }
660
661    #[test]
662    fn no_extends_returns_nodes_unchanged() {
663        let nodes = parse("just text").unwrap();
664        let resolved = resolve(nodes.clone(), &mut |_| unreachable!()).unwrap();
665        assert_eq!(resolved, nodes);
666    }
667
668    #[test]
669    fn multi_level_layout_chain_resolves() {
670        let base = parse("[@yield('body')]").unwrap();
671        let app = parse("@extends('base')@section('body')<@yield('inner')>@endsection").unwrap();
672        let page = parse("@extends('app')@section('inner')hi@endsection").unwrap();
673
674        let resolved = resolve(page, &mut |name| match name {
675            "app" => Ok(app.clone()),
676            "base" => Ok(base.clone()),
677            other => panic!("unexpected load({other})"),
678        })
679        .unwrap();
680
681        let rendered: String = resolved
682            .iter()
683            .map(|n| match n {
684                Node::Text(t) => t.as_str(),
685                _ => "",
686            })
687            .collect();
688        assert_eq!(rendered, "[<hi>]");
689    }
690
691    #[test]
692    fn yield_inside_conditional_is_substituted() {
693        let layout = parse("@if(show) @yield('content') @endif").unwrap();
694        let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
695
696        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
697
698        let Node::If { then_branch, .. } = &resolved[0] else {
699            panic!("expected If node");
700        };
701        assert!(then_branch.contains(&Node::Text("hi".to_string())));
702    }
703
704    #[test]
705    fn yield_inside_spa_is_substituted() {
706        // The load-bearing case for `@spa`'s whole design (see `Node::Spa`'s
707        // own doc comment in `ast.rs`): its body needs `substitute_yields`
708        // to reach the `@yield('content')` sitting inside it exactly as it
709        // already does for `@if`'s own `then_branch` above.
710        let layout = parse("@spa @yield('content') @endspa").unwrap();
711        let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
712
713        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
714
715        let Node::Spa(body) = &resolved[0] else {
716            panic!("expected Spa node");
717        };
718        assert!(body.contains(&Node::Text("hi".to_string())));
719    }
720
721    #[test]
722    fn yield_inside_an_elseif_branch_is_substituted() {
723        // `@elseif` desugars into a nested `Node::If` inside the outer
724        // `else_branch` (see `larust-view::parser`) - this pins that
725        // `substitute_yields`'s generic, unconditional recursion into both
726        // `then_branch`/`else_branch` correctly reaches into that nested
727        // node too, not just a single top-level `@if`.
728        let layout = parse("@if(a) skip @elseif(b) @yield('content') @else skip @endif").unwrap();
729        let child = parse("@extends('layout')@section('content')hi@endsection").unwrap();
730
731        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
732
733        let Node::If {
734            else_branch: outer_else,
735            ..
736        } = &resolved[0]
737        else {
738            panic!("expected outer If node");
739        };
740        let Node::If {
741            then_branch: nested_then,
742            ..
743        } = &outer_else[0]
744        else {
745            panic!("expected nested If node from @elseif");
746        };
747        assert!(nested_then.contains(&Node::Text("hi".to_string())));
748    }
749
750    #[test]
751    fn self_extending_template_errors_instead_of_overflowing() {
752        let a = parse("@extends('a')").unwrap();
753
754        let result = resolve(a.clone(), &mut |name| {
755            assert_eq!(name, "a");
756            Ok(a.clone())
757        });
758
759        assert!(result.is_err(), "expected a cycle error, got Ok");
760    }
761
762    #[test]
763    fn mutually_extending_templates_error_instead_of_overflowing() {
764        let a = parse("@extends('b')").unwrap();
765        let b = parse("@extends('a')").unwrap();
766
767        let result = resolve(a, &mut |name| match name {
768            "a" => Ok(a_clone()),
769            "b" => Ok(b.clone()),
770            other => panic!("unexpected load({other})"),
771        });
772
773        assert!(result.is_err(), "expected a cycle error, got Ok");
774
775        fn a_clone() -> Vec<Node> {
776            parse("@extends('b')").unwrap()
777        }
778    }
779
780    #[test]
781    fn stack_renders_a_single_push() {
782        let layout = parse("<head>@stack('scripts')</head>").unwrap();
783        let child = parse("@extends('layout')@push('scripts')<script>a</script>@endpush").unwrap();
784
785        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
786
787        assert_eq!(
788            resolved,
789            vec![
790                Node::Text("<head>".to_string()),
791                Node::Text("<script>a</script>".to_string()),
792                Node::Text("</head>".to_string()),
793            ]
794        );
795    }
796
797    #[test]
798    fn multiple_pushes_to_the_same_stack_accumulate_in_source_order() {
799        // Unlike `@section` (last-write-wins), two `@push`es to the same
800        // name both contribute - this is the entire reason `@push`/`@stack`
801        // exists instead of just reusing `@section`/`@yield`.
802        let layout = parse("@stack('scripts')").unwrap();
803        let child = parse(
804            "@extends('layout')\
805             @push('scripts')one@endpush\
806             @push('scripts')two@endpush",
807        )
808        .unwrap();
809
810        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
811
812        assert_eq!(
813            resolved,
814            vec![Node::Text("one".to_string()), Node::Text("two".to_string())]
815        );
816    }
817
818    #[test]
819    fn pushes_from_every_level_of_a_layout_chain_reach_the_base_stack() {
820        // The base layout's `@stack` must see contributions from *both*
821        // `app` and `page` - proving pushes are collected across the whole
822        // `@extends` chain, not just the single level whose
823        // `substitute_yields` call happens to run first.
824        let base = parse("[@stack('scripts')]").unwrap();
825        let app = parse("@extends('base')@push('scripts')from-app@endpush").unwrap();
826        let page = parse("@extends('app')@push('scripts')from-page@endpush").unwrap();
827
828        let resolved = resolve(page, &mut |name| match name {
829            "app" => Ok(app.clone()),
830            "base" => Ok(base.clone()),
831            other => panic!("unexpected load({other})"),
832        })
833        .unwrap();
834
835        let rendered: String = resolved
836            .iter()
837            .map(|n| match n {
838                Node::Text(t) => t.as_str(),
839                _ => "",
840            })
841            .collect();
842        assert_eq!(rendered, "[from-pagefrom-app]");
843    }
844
845    #[test]
846    fn stack_with_no_matching_push_leaves_a_runtime_drain_marker() {
847        // A `@stack` with nothing pushed to it at compile time doesn't
848        // vanish outright - it's left behind as a `Node::Stack` runtime
849        // drain point, so codegen can still consult
850        // `larust_view::push_registry` for content pushed across a
851        // boundary this compile-time-only pass can't see (a `<wire:...>`
852        // mount, or a separately-composed `.into_html()` page). See
853        // `substitute_stacks`'s own doc comment.
854        let layout = parse("<head>@stack('scripts')</head>").unwrap();
855        let child = parse("@extends('layout')").unwrap();
856
857        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
858
859        assert_eq!(
860            resolved,
861            vec![
862                Node::Text("<head>".to_string()),
863                Node::Stack("scripts".to_string()),
864                Node::Text("</head>".to_string()),
865            ]
866        );
867    }
868
869    #[test]
870    fn a_push_inside_a_resource_taged_templates_own_file_reaches_an_outer_stack() {
871        // Regression test for the real bug this fix addresses: a `@push`
872        // doesn't have to live at a `<resource:...>` tag's *call site*
873        // (its `slot`) to be seen - it can live inside the resource's own
874        // named template file (real source: `livewire.components.head`,
875        // included via `<resource:...>` from every page, wraps its entire
876        // body - title, meta description, OG tags - in `@push('head')`).
877        // Before this fix, `collect_pushes` only ever recursed into a
878        // `Node::Resource`'s `slot`, never `load()`ed and scanned its own
879        // named file, so this content was silently dropped everywhere.
880        let head = parse("@push('head')<title>hi</title>@endpush").unwrap();
881        let page = parse("<head>@stack('head')</head><resource:head></resource:head>").unwrap();
882
883        let resolved = resolve(page, &mut |name| {
884            assert_eq!(name, "head");
885            Ok(head.clone())
886        })
887        .unwrap();
888
889        let rendered: String = resolved
890            .iter()
891            .map(|n| match n {
892                Node::Text(t) => t.as_str(),
893                _ => "",
894            })
895            .collect();
896        assert_eq!(rendered, "<head><title>hi</title></head>");
897    }
898
899    #[test]
900    fn a_stack_inside_a_resource_tagged_templates_own_file_receives_an_outer_push() {
901        // The other half of the same real bug: `@stack('head')` living
902        // *inside* the resource file (real source: `components.layouts.
903        // app`, included via `<resource:...>` from every page's wire
904        // shell) never received pushes from the page that included it,
905        // because that resource file is loaded and codegen'd entirely
906        // outside `resolve()`'s own traversal. Mirrors exactly what
907        // `larust-macros`' `Node::Resource` codegen arm now does: apply
908        // `substitute_stacks` to a resource's freshly-loaded nodes using
909        // the whole-tree `pushes` map `resolve_with_context` returns.
910        let layout = parse("<head>@stack('head')</head>").unwrap();
911        let page =
912            parse("@push('head')<title>hi</title>@endpush<resource:layout></resource:layout>")
913                .unwrap();
914
915        let (_, (pushes, _)) = resolve_with_context(page, &mut |name| {
916            assert_eq!(name, "layout");
917            Ok(layout.clone())
918        })
919        .unwrap();
920
921        let resolved_layout = substitute_stacks(layout, &pushes);
922        let rendered: String = resolved_layout
923            .iter()
924            .map(|n| match n {
925                Node::Text(t) => t.as_str(),
926                _ => "",
927            })
928            .collect();
929        assert_eq!(rendered, "<head><title>hi</title></head>");
930    }
931
932    #[test]
933    fn push_nested_inside_another_push_still_reaches_its_own_stack() {
934        // Regression test: `collect_pushes` originally only registered a
935        // `@push`'s own body under its own name - it never recursed *into*
936        // that body looking for a further-nested `@push`, so this exact
937        // case silently dropped "nested" entirely (registered nowhere).
938        let layout = parse("[@stack('outer')][@stack('inner')]").unwrap();
939        let child =
940            parse("@extends('layout')@push('outer')@push('inner')nested@endpush@endpush").unwrap();
941
942        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
943
944        let rendered: String = resolved
945            .iter()
946            .map(|n| match n {
947                Node::Text(t) => t.as_str(),
948                _ => "",
949            })
950            .collect();
951        assert_eq!(rendered, "[][nested]");
952    }
953
954    #[test]
955    fn push_inside_foreach_is_rejected_with_a_clear_error() {
956        // Regression test: `@push`/`@stack` resolve once, statically, at
957        // compile time - there is no per-iteration runtime step the way
958        // Laravel's own imperative Blade compiler has, so a push inside a
959        // loop must be rejected rather than silently rendering once
960        // instead of once-per-item (or, worse, referencing the loop
961        // variable and failing to compile with a confusing error far from
962        // the actual template source).
963        let layout = parse("@stack('scripts')").unwrap();
964        let child = parse(
965            "@extends('layout')\
966             @foreach(item in items)@push('scripts'){{ item }}@endpush@endforeach",
967        )
968        .unwrap();
969
970        let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
971        assert!(err.to_string().contains("@push"));
972        assert!(err.to_string().contains("@foreach"));
973    }
974
975    #[test]
976    fn push_inside_a_nested_foreach_is_also_rejected() {
977        let layout = parse("@stack('scripts')").unwrap();
978        let child = parse(
979            "@extends('layout')\
980             @foreach(group in groups)@foreach(item in group.items)\
981             @push('scripts'){{ item }}@endpush\
982             @endforeach@endforeach",
983        )
984        .unwrap();
985
986        assert!(resolve(child, &mut |_| Ok(layout.clone())).is_err());
987    }
988
989    #[test]
990    fn global_reaches_through_an_indifferent_middle_layout() {
991        // The exact bug pattern `@section`/`@yield`'s eager per-level
992        // resolution has and `@push`/`@stack`'s two-pass design avoids:
993        // `app` (the middle layout) doesn't set `@globals` at all, so a
994        // per-level-eager resolver would blank `base`'s `@global(title)`
995        // before `page`'s `@globals` ever got a chance. The whole-chain
996        // collect-then-substitute design here must not have that gap.
997        let base = parse("<title>@global(title)</title>").unwrap();
998        let app = parse("@extends('base')").unwrap();
999        let page = parse("@extends('app')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
1000
1001        let resolved = resolve(page, &mut |name| match name {
1002            "app" => Ok(app.clone()),
1003            "base" => Ok(base.clone()),
1004            other => panic!("unexpected load({other})"),
1005        })
1006        .unwrap();
1007
1008        assert_eq!(
1009            resolved,
1010            vec![
1011                Node::Text("<title>".to_string()),
1012                Node::Interpolate {
1013                    expr: "\"Hi\"".to_string(),
1014                    escape: true,
1015                },
1016                Node::Text("</title>".to_string()),
1017            ]
1018        );
1019    }
1020
1021    #[test]
1022    fn globals_from_child_page_override_a_middle_layout_that_also_sets_it() {
1023        let base = parse("<title>@global(title)</title>").unwrap();
1024        let app = parse("@extends('base')@globals\ntitle = \"App default\"\n@endglobals").unwrap();
1025        let page =
1026            parse("@extends('app')@globals\ntitle = \"Page specific\"\n@endglobals").unwrap();
1027
1028        let resolved = resolve(page, &mut |name| match name {
1029            "app" => Ok(app.clone()),
1030            "base" => Ok(base.clone()),
1031            other => panic!("unexpected load({other})"),
1032        })
1033        .unwrap();
1034
1035        assert_eq!(
1036            resolved,
1037            vec![
1038                Node::Text("<title>".to_string()),
1039                Node::Interpolate {
1040                    expr: "\"Page specific\"".to_string(),
1041                    escape: true,
1042                },
1043                Node::Text("</title>".to_string()),
1044            ]
1045        );
1046    }
1047
1048    #[test]
1049    fn global_with_no_matching_globals_becomes_empty() {
1050        let layout = parse("<title>@global(title)</title>").unwrap();
1051        let child = parse("@extends('layout')").unwrap();
1052
1053        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1054
1055        assert_eq!(
1056            resolved,
1057            vec![
1058                Node::Text("<title>".to_string()),
1059                Node::Text("</title>".to_string()),
1060            ]
1061        );
1062    }
1063
1064    #[test]
1065    fn global_falls_back_to_its_default_when_nothing_sets_it() {
1066        let layout = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
1067        let child = parse("@extends('layout')").unwrap();
1068
1069        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1070
1071        assert_eq!(
1072            resolved,
1073            vec![
1074                Node::Text("<title>".to_string()),
1075                Node::Interpolate {
1076                    expr: "\"Larust\"".to_string(),
1077                    escape: true,
1078                },
1079                Node::Text("</title>".to_string()),
1080            ]
1081        );
1082    }
1083
1084    #[test]
1085    fn a_matching_globals_entry_wins_over_the_fallback() {
1086        let layout = parse(r#"<title>@global(title, "Larust")</title>"#).unwrap();
1087        let child = parse("@extends('layout')@globals\ntitle = \"Page\"\n@endglobals").unwrap();
1088
1089        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1090
1091        assert_eq!(
1092            resolved,
1093            vec![
1094                Node::Text("<title>".to_string()),
1095                Node::Interpolate {
1096                    expr: "\"Page\"".to_string(),
1097                    escape: true,
1098                },
1099                Node::Text("</title>".to_string()),
1100            ]
1101        );
1102    }
1103
1104    #[test]
1105    fn multiple_globals_blocks_accumulate_different_names() {
1106        let layout = parse("[@global(a)][@global(b)]").unwrap();
1107        let child = parse(
1108            "@extends('layout')\
1109             @globals\na = \"1\"\n@endglobals\
1110             @globals\nb = \"2\"\n@endglobals",
1111        )
1112        .unwrap();
1113
1114        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1115
1116        let rendered: String = resolved
1117            .iter()
1118            .map(|n| match n {
1119                Node::Text(t) => t.as_str(),
1120                Node::Interpolate { expr, .. } => expr.as_str(),
1121                _ => "",
1122            })
1123            .collect();
1124        assert_eq!(rendered, "[\"1\"][\"2\"]");
1125    }
1126
1127    #[test]
1128    fn globals_inside_foreach_is_rejected_with_a_clear_error() {
1129        let layout = parse("@global(title)").unwrap();
1130        let child = parse(
1131            "@extends('layout')\
1132             @foreach(item in items)@globals\ntitle = item\n@endglobals@endforeach",
1133        )
1134        .unwrap();
1135
1136        let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
1137        assert!(err.to_string().contains("@globals"));
1138        assert!(err.to_string().contains("@foreach"));
1139    }
1140
1141    #[test]
1142    fn globals_inside_if_is_rejected_with_a_clear_error() {
1143        // Regression test: a compile-time collection pass can't express
1144        // "pick whichever branch the condition selects at runtime" - before
1145        // this rejection existed, whichever branch was walked *last*
1146        // (`else_branch`) always silently won, regardless of the `@if`
1147        // condition's actual runtime value.
1148        let layout = parse("@global(title)").unwrap();
1149        let child = parse(
1150            "@extends('layout')\
1151             @if(is_admin)@globals\ntitle = \"Admin\"\n@endglobals\
1152             @else@globals\ntitle = \"User\"\n@endglobals@endif",
1153        )
1154        .unwrap();
1155
1156        let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
1157        assert!(err.to_string().contains("@globals"));
1158        assert!(err.to_string().contains("@if"));
1159    }
1160
1161    #[test]
1162    fn globals_inside_can_is_rejected_with_a_clear_error() {
1163        let layout = parse("@global(title)").unwrap();
1164        let child = parse(
1165            "@extends('layout')\
1166             @can(Permission::EditPosts)@globals\ntitle = \"Editor\"\n@endglobals@endcan",
1167        )
1168        .unwrap();
1169
1170        let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
1171        assert!(err.to_string().contains("@globals"));
1172        assert!(err.to_string().contains("@can"));
1173    }
1174
1175    #[test]
1176    fn globals_inside_role_is_rejected_with_a_clear_error() {
1177        let layout = parse("@global(title)").unwrap();
1178        let child = parse(
1179            "@extends('layout')\
1180             @role(Role::Admin)@globals\ntitle = \"Admin\"\n@endglobals@endrole",
1181        )
1182        .unwrap();
1183
1184        let err = resolve(child, &mut |_| Ok(layout.clone())).unwrap_err();
1185        assert!(err.to_string().contains("@globals"));
1186        assert!(err.to_string().contains("@role"));
1187    }
1188
1189    #[test]
1190    fn global_placeholder_inside_can_is_still_substituted() {
1191        // Unlike a `@globals` *block* (rejected above), a `@global(...)`
1192        // *read* inside `@can`/`@role` is fine - only compile-time
1193        // collection (deciding a value) is the problem, not using an
1194        // already-resolved value inside a conditionally-rendered branch.
1195        let layout = parse("@can(Permission::EditPosts)@global(title)@endcan").unwrap();
1196        let child = parse("@extends('layout')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
1197
1198        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1199
1200        let Node::Can { then_branch, .. } = &resolved[0] else {
1201            panic!("expected Can node");
1202        };
1203        assert_eq!(
1204            then_branch[0],
1205            Node::Interpolate {
1206                expr: "\"Hi\"".to_string(),
1207                escape: true,
1208            }
1209        );
1210    }
1211
1212    #[test]
1213    fn globals_inside_elseif_is_also_rejected() {
1214        // `@elseif` desugars into a nested `Node::If` in the outer
1215        // `else_branch` - confirms the rejection reaches that nested case
1216        // too, not just a single top-level `@if`.
1217        let layout = parse("@global(title)").unwrap();
1218        let child = parse(
1219            "@extends('layout')\
1220             @if(a) x @elseif(b)@globals\ntitle = \"B\"\n@endglobals@endif",
1221        )
1222        .unwrap();
1223
1224        assert!(resolve(child, &mut |_| Ok(layout.clone())).is_err());
1225    }
1226
1227    #[test]
1228    fn global_works_without_any_extends_at_all() {
1229        // Unlike `@section`/`@yield` (which never resolve at all without an
1230        // `@extends`), `@global`/`@globals` follow `@push`/`@stack`'s
1231        // shape: `collect_globals` runs unconditionally at the top of
1232        // `resolve_inner`, before the `@extends` check, so a single
1233        // standalone template can set and read its own global.
1234        let nodes =
1235            parse("@globals\ntitle = \"Solo\"\n@endglobals<title>@global(title)</title>").unwrap();
1236
1237        let resolved = resolve(nodes, &mut |_| unreachable!()).unwrap();
1238
1239        // The leftover `Node::Globals` node itself (never consumed by
1240        // `resolve()` - same as an unresolved `Node::Push` - since there's
1241        // no `@extends` chain to consume its metadata into) renders as
1242        // nothing via codegen's fallback, same story as an unresolved
1243        // `Push`; a rendered-string comparison (rather than exact `Vec<Node>`
1244        // equality) reflects that without needing to spell out that leftover
1245        // node here.
1246        let rendered: String = resolved
1247            .iter()
1248            .map(|n| match n {
1249                Node::Text(t) => t.as_str(),
1250                Node::Interpolate { expr, .. } => expr.as_str(),
1251                _ => "",
1252            })
1253            .collect();
1254        assert_eq!(rendered, "<title>\"Solo\"</title>");
1255    }
1256
1257    #[test]
1258    fn persist_global_substitutes_to_a_persist_global_node_not_interpolate() {
1259        let layout = parse(r#"<html data-theme="@global(theme, "dark")">"#).unwrap();
1260        let child =
1261            parse("@extends('layout')@globals\npersist theme = \"light\"\n@endglobals").unwrap();
1262
1263        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1264
1265        assert_eq!(
1266            resolved,
1267            vec![
1268                Node::Text("<html data-theme=\"".to_string()),
1269                Node::PersistGlobal {
1270                    cookie_name: "theme".to_string(),
1271                    fallback_expr: "\"light\"".to_string(),
1272                },
1273                Node::Text("\">".to_string()),
1274            ]
1275        );
1276    }
1277
1278    #[test]
1279    fn a_non_persist_global_still_substitutes_to_interpolate() {
1280        let layout = parse("<title>@global(title)</title>").unwrap();
1281        let child = parse("@extends('layout')@globals\ntitle = \"Hi\"\n@endglobals").unwrap();
1282
1283        let resolved = resolve(child, &mut |_| Ok(layout.clone())).unwrap();
1284
1285        assert_eq!(
1286            resolved,
1287            vec![
1288                Node::Text("<title>".to_string()),
1289                Node::Interpolate {
1290                    expr: "\"Hi\"".to_string(),
1291                    escape: true,
1292                },
1293                Node::Text("</title>".to_string()),
1294            ]
1295        );
1296    }
1297
1298    #[test]
1299    fn a_child_pages_persist_entry_still_overrides_a_middle_layout_that_also_sets_it() {
1300        // Same "child wins, indifferent middle layout doesn't block it"
1301        // shape as `globals_from_child_page_override_a_middle_layout_that_also_sets_it`
1302        // above, just for a `persist` entry - proving `GlobalEntry`'s
1303        // `persist` flag travels correctly through the same child-wins
1304        // merge, not just the plain-expression case.
1305        let base = parse("<title>@global(theme, \"dark\")</title>").unwrap();
1306        let app = parse("@extends('base')@globals\npersist theme = \"app-default\"\n@endglobals")
1307            .unwrap();
1308        let page = parse("@extends('app')@globals\npersist theme = \"page-specific\"\n@endglobals")
1309            .unwrap();
1310
1311        let resolved = resolve(page, &mut |name| match name {
1312            "app" => Ok(app.clone()),
1313            "base" => Ok(base.clone()),
1314            other => panic!("unexpected load({other})"),
1315        })
1316        .unwrap();
1317
1318        assert_eq!(
1319            resolved,
1320            vec![
1321                Node::Text("<title>".to_string()),
1322                Node::PersistGlobal {
1323                    cookie_name: "theme".to_string(),
1324                    fallback_expr: "\"page-specific\"".to_string(),
1325                },
1326                Node::Text("</title>".to_string()),
1327            ]
1328        );
1329    }
1330}