Skip to main content

gpui_navigator/
resolve.rs

1//! Route resolution via Match Stack
2//!
3//! # Architecture
4//!
5//! Instead of each `RouterOutlet` independently searching the route tree at render time,
6//! we resolve the **entire chain of matched routes once** when navigation occurs.
7//!
8//! The result is a `MatchStack` — an ordered list of `MatchEntry` items, one per nesting level.
9//! Each `RouterOutlet` simply reads its entry by depth index.
10//!
11//! # Example
12//!
13//! Given routes:
14//! ```text
15//! /              (root layout)
16//!   dashboard    (has children)
17//!     ""         (index → overview)
18//!     settings   (has children)
19//!       profile  (leaf)
20//!     :id        (param leaf)
21//! ```
22//!
23//! For path `/dashboard/settings/profile`, the match stack is:
24//! ```text
25//! [0] Route("/")          params={}                 ← router_view renders this
26//! [1] Route("dashboard")  params={}                 ← outlet depth 1
27//! [2] Route("settings")   params={}                 ← outlet depth 2
28//! [3] Route("profile")    params={}                 ← outlet depth 3
29//! ```
30//!
31//! For path `/dashboard/42`:
32//! ```text
33//! [0] Route("/")          params={}                 ← router_view renders this
34//! [1] Route("dashboard")  params={}                 ← outlet depth 1
35//! [2] Route(":id")        params={id: "42"}         ← outlet depth 2
36//! ```
37//!
38//! # Depth Tracking
39//!
40//! Outlets discover their depth via a thread-local counter:
41//! - `router_view()` resets depth to 0, renders `match_stack[0]`
42//! - Each outlet sets depth = `parent_depth` + 1 and renders `match_stack[depth]`
43//! - Works for both functional (`render_router_outlet`) and entity (`RouterOutlet`) APIs
44
45use crate::nested::{normalize_path, trim_slashes};
46use crate::route::Route;
47use crate::{debug_log, trace_log, warn_log, RouteParams};
48use std::cell::Cell;
49use std::sync::Arc;
50
51// ============================================================================
52// Depth Tracking (thread-local, PARENT_DEPTH approach)
53// ============================================================================
54//
55// # Why not NESTING counter?
56//
57// GPUI renders Entity<T> **after** the parent builder returns — NOT inline.
58// `Route::component()` returns `Entity<T>.into_any_element()` as a blueprint;
59// GPUI calls `T::render()` later during layout/paint.
60//
61// This means any save/restore pattern (enter/exit with NESTING counter)
62// breaks: by the time child `RouterOutlet::render()` runs, the parent
63// already called `exit_outlet()` and the counter is reset.
64//
65// # Solution: PARENT_DEPTH
66//
67// A single thread-local `Option<usize>`:
68// - `None` → next outlet is ROOT → depth = 0
69// - `Some(d)` → next outlet is CHILD of depth `d` → depth = d + 1
70//
71// Each outlet:
72// 1. Reads PARENT_DEPTH to determine its own depth
73// 2. Sets PARENT_DEPTH = Some(my_depth) before `route.build()`
74// 3. Does NOT restore PARENT_DEPTH after build
75//
76// This works because GPUI renders depth-first: when child `T::render()` runs,
77// PARENT_DEPTH still holds the value set by its parent outlet.
78//
79// # Render flow
80//
81// ```text
82// NestedDemoApp::render()                   PARENT_DEPTH=None
83//   └─ .child(self.outlet.clone())
84//      // GPUI calls RouterOutlet::render()
85//      RouterOutlet::render()
86//        PARENT_DEPTH=None → ROOT → my_depth=0
87//        set PARENT_DEPTH=Some(0)
88//        route.build() → Entity<DashboardLayout>.into_any_element()
89//        (no restore!)
90//
91//      // GPUI processes element tree, calls DashboardLayout::render()
92//      DashboardLayout::render()            PARENT_DEPTH=Some(0)
93//        .child(outlet.clone())
94//        // GPUI calls child RouterOutlet::render()
95//        RouterOutlet::render()
96//          PARENT_DEPTH=Some(0) → CHILD → my_depth=1
97//          set PARENT_DEPTH=Some(1)
98//          route.build() → AnalyticsPage
99//          stack.at_depth(1) → Route("analytics")
100// ```
101
102thread_local! {
103    /// Depth of the parent outlet that last called `route.build()`.
104    /// `None` means no parent → next outlet is root (depth 0).
105    /// `Some(d)` means parent is at depth `d` → next outlet is at `d + 1`.
106    ///
107    /// Used ONLY for initial depth discovery when an outlet first renders.
108    /// After that, outlets store their depth in their own field.
109    static PARENT_DEPTH: Cell<Option<usize>> = const { Cell::new(None) };
110}
111
112/// Discover the depth for a NEW outlet rendering for the first time.
113///
114/// Returns `my_depth` — the match stack index this outlet should render.
115///
116/// - If `PARENT_DEPTH` is `None`: this is ROOT → depth = 0
117/// - If `PARENT_DEPTH` is `Some(d)`: this is CHILD → depth = d + 1
118///
119/// Also sets `PARENT_DEPTH = Some(my_depth)` so that child outlets
120/// created inside this outlet's builder get the correct depth.
121///
122/// This should only be called ONCE per outlet (on first render).
123/// After that, use `set_parent_depth()` with the saved depth.
124pub fn enter_outlet() -> usize {
125    let parent = PARENT_DEPTH.with(Cell::get);
126
127    let my_depth = parent.map_or(0, |d| d + 1);
128
129    // Set for children rendered inside our builder
130    PARENT_DEPTH.with(|p| p.set(Some(my_depth)));
131
132    my_depth
133}
134
135/// Set `PARENT_DEPTH` to `depth` so child outlets see the correct parent.
136///
137/// Called by outlets that already know their depth (from a previous render).
138/// This ensures that child outlets created via `enter_outlet()` or
139/// rendered as deferred Entity components get `depth + 1`.
140pub fn set_parent_depth(depth: usize) {
141    PARENT_DEPTH.with(|p| p.set(Some(depth)));
142}
143
144/// Reset outlet tracking state to "no parent".
145///
146/// Called by `router_view()` at the start of a render cycle,
147/// or between render passes to ensure clean state.
148pub fn reset_outlet_depth() {
149    PARENT_DEPTH.with(|p| p.set(None));
150}
151
152/// Get current outlet depth without modifying state. Used by named outlets.
153#[must_use]
154pub fn current_outlet_depth() -> usize {
155    PARENT_DEPTH.with(|p| p.get().map_or(0, |d| d + 1))
156}
157
158/// Get the raw parent depth value (for debugging/testing).
159pub fn current_parent_depth() -> Option<usize> {
160    PARENT_DEPTH.with(Cell::get)
161}
162
163// ============================================================================
164// Match Stack
165// ============================================================================
166
167/// A single entry in the route match stack.
168///
169/// Represents one level of the route hierarchy that matched the current path.
170#[derive(Debug, Clone)]
171pub struct MatchEntry {
172    /// The matched route at this level
173    pub route: Arc<Route>,
174    /// Accumulated params (includes all params from parent levels + this level)
175    pub params: RouteParams,
176    /// Depth in the hierarchy (0 = root/top-level route)
177    pub depth: usize,
178}
179
180/// The full resolved route chain for the current path.
181///
182/// Built once per navigation, consumed by outlets during rendering.
183/// Each outlet reads its entry by depth index.
184#[derive(Debug, Clone, Default)]
185pub struct MatchStack {
186    entries: Vec<MatchEntry>,
187}
188
189impl MatchStack {
190    /// Create an empty match stack.
191    #[must_use]
192    pub const fn new() -> Self {
193        Self {
194            entries: Vec::new(),
195        }
196    }
197
198    /// Return the entry at `depth`, or `None` if out of range.
199    #[must_use]
200    pub fn at_depth(&self, depth: usize) -> Option<&MatchEntry> {
201        self.entries.get(depth)
202    }
203
204    /// Return the root (depth 0) entry, or `None` if the stack is empty.
205    #[must_use]
206    pub fn root(&self) -> Option<&MatchEntry> {
207        self.entries.first()
208    }
209
210    /// Return the leaf (deepest) entry, or `None` if the stack is empty.
211    #[must_use]
212    pub fn leaf(&self) -> Option<&MatchEntry> {
213        self.entries.last()
214    }
215
216    /// Return the total number of matched levels in the stack.
217    #[must_use]
218    pub fn len(&self) -> usize {
219        self.entries.len()
220    }
221
222    /// Return `true` if no routes matched the path.
223    #[must_use]
224    pub fn is_empty(&self) -> bool {
225        self.entries.is_empty()
226    }
227
228    /// Return the maximum depth (0-indexed), or `None` if the stack is empty.
229    #[must_use]
230    pub fn max_depth(&self) -> Option<usize> {
231        if self.entries.is_empty() {
232            None
233        } else {
234            Some(self.entries.len() - 1)
235        }
236    }
237
238    /// Return all entries as a slice (ordered root → leaf).
239    #[must_use]
240    pub fn entries(&self) -> &[MatchEntry] {
241        &self.entries
242    }
243
244    /// Return the accumulated params at the deepest matched level.
245    #[must_use]
246    pub fn params(&self) -> RouteParams {
247        self.leaf().map(|e| e.params.clone()).unwrap_or_default()
248    }
249
250    /// Return `true` if the stack contains an entry at the given `depth`.
251    #[must_use]
252    pub fn has_depth(&self, depth: usize) -> bool {
253        depth < self.entries.len()
254    }
255
256    /// Return a multi-line human-readable representation (debug builds only).
257    #[cfg(debug_assertions)]
258    #[must_use]
259    pub fn debug_string(&self) -> String {
260        if self.entries.is_empty() {
261            return "MatchStack: (empty)".to_string();
262        }
263
264        let mut lines = vec!["MatchStack:".to_string()];
265        for entry in &self.entries {
266            let indent = "  ".repeat(entry.depth);
267            let params_str = if entry.params.is_empty() {
268                String::new()
269            } else {
270                format!(
271                    " params={{{}}}",
272                    entry
273                        .params
274                        .iter()
275                        .map(|(k, v)| format!("{k}={v}"))
276                        .collect::<Vec<_>>()
277                        .join(", ")
278                )
279            };
280            lines.push(format!(
281                "{}[{}] Route(\"{}\"){}",
282                indent, entry.depth, entry.route.config.path, params_str
283            ));
284        }
285        lines.join("\n")
286    }
287}
288
289// ============================================================================
290// Resolution Algorithm
291// ============================================================================
292
293/// Maximum nesting depth to prevent infinite recursion
294const MAX_DEPTH: usize = 16;
295
296/// Resolve the full match stack for a given path against the route tree.
297///
298/// This is called once per navigation and produces a `MatchStack` that
299/// outlets consume during rendering.
300///
301/// # Algorithm
302///
303/// 1. Split path into segments: `/dashboard/settings/profile` → `["dashboard", "settings", "profile"]`
304/// 2. Try each top-level route against the first segment(s)
305/// 3. On match, consume segments and recurse into children
306/// 4. At each level, push a `MatchEntry` into the stack
307/// 5. When segments exhausted, try index route (empty path child)
308///
309/// # Examples
310///
311/// ```ignore
312/// use gpui_navigator::resolve::resolve_match_stack;
313///
314/// let stack = resolve_match_stack(&routes, "/dashboard/settings/profile");
315/// assert_eq!(stack.len(), 4); // root, dashboard, settings, profile
316/// ```
317#[must_use]
318pub fn resolve_match_stack(routes: &[Arc<Route>], path: &str) -> MatchStack {
319    let normalized = normalize_path(path);
320    let path_str = trim_slashes(&normalized);
321
322    let segments: Vec<&str> = if path_str.is_empty() {
323        vec![]
324    } else {
325        path_str.split('/').collect()
326    };
327
328    let mut stack = MatchStack::new();
329    resolve_recursive(routes, &segments, 0, &RouteParams::new(), &mut stack);
330
331    if stack.is_empty() {
332        warn_log!("No route matched path '{}'", path);
333    } else {
334        debug_log!(
335            "Resolved path '{}' → {} levels: [{}]",
336            path,
337            stack.len(),
338            stack
339                .entries
340                .iter()
341                .map(|e| format!("\"{}\"", e.route.config.path))
342                .collect::<Vec<_>>()
343                .join(" → ")
344        );
345    }
346
347    stack
348}
349
350/// Recursive route matching with backtracking.
351///
352/// Returns `true` if a complete match was found (all segments consumed or
353/// a valid leaf/index route was reached).
354#[allow(clippy::too_many_lines)]
355fn resolve_recursive(
356    routes: &[Arc<Route>],
357    remaining: &[&str],
358    depth: usize,
359    inherited_params: &RouteParams,
360    stack: &mut MatchStack,
361) -> bool {
362    // Safety: prevent infinite recursion
363    if depth >= MAX_DEPTH {
364        warn_log!(
365            "Maximum route nesting depth ({}) exceeded. Check for circular routes.",
366            MAX_DEPTH
367        );
368        return false;
369    }
370
371    for route in routes {
372        let route_path = trim_slashes(&route.config.path);
373
374        trace_log!(
375            "Trying route '{}' at depth {} ({} remaining segments)",
376            route_path,
377            depth,
378            remaining.len()
379        );
380
381        // === Try to match this route's segments ===
382
383        // Case 1: Route has an empty path (index/layout route)
384        if route_path.is_empty() {
385            // Empty-path route with children = layout route (matches anything)
386            // Empty-path route without children = index route (matches only when no segments left)
387            if remaining.is_empty() {
388                // No segments left → this is an index/layout match
389                stack.entries.push(MatchEntry {
390                    route: Arc::clone(route),
391                    params: inherited_params.clone(),
392                    depth,
393                });
394
395                // If layout with children, try to resolve index child
396                if !route.children.is_empty() {
397                    try_index_route(&route.children, depth + 1, inherited_params, stack);
398                }
399                return true;
400            }
401
402            // Segments remain and route has children → layout route wrapping children
403            if !route.children.is_empty() {
404                stack.entries.push(MatchEntry {
405                    route: Arc::clone(route),
406                    params: inherited_params.clone(),
407                    depth,
408                });
409
410                if resolve_recursive(
411                    &route.children,
412                    remaining,
413                    depth + 1,
414                    inherited_params,
415                    stack,
416                ) {
417                    return true;
418                }
419
420                // Children didn't match → backtrack
421                stack.entries.pop();
422            }
423
424            continue;
425        }
426
427        let route_segments: Vec<&str> = route_path.split('/').collect();
428
429        // Case 2: Route has path segments → try to match against remaining path
430        if route_segments.len() > remaining.len() {
431            continue; // Not enough path segments
432        }
433
434        let mut params = inherited_params.clone();
435        let mut matched = true;
436
437        for (i, route_seg) in route_segments.iter().enumerate() {
438            if route_seg.starts_with(':') {
439                // Parameter segment → extract value
440                let param_name = route_seg.trim_start_matches(':');
441                // Strip constraint syntax: `:id<i32>` → `id`
442                let param_name = param_name
443                    .find('<')
444                    .map_or(param_name, |pos| &param_name[..pos]);
445                params.insert(param_name.to_string(), remaining[i].to_string());
446            } else if *route_seg == remaining[i] {
447                // Static segment → exact match
448            } else {
449                matched = false;
450                break;
451            }
452        }
453
454        if !matched {
455            continue;
456        }
457
458        // Segments matched! Push entry.
459        let consumed = route_segments.len();
460        let after = &remaining[consumed..];
461
462        trace_log!(
463            "Matched route '{}' at depth {}, params: {:?}",
464            route_path,
465            depth,
466            params.all()
467        );
468
469        stack.entries.push(MatchEntry {
470            route: Arc::clone(route),
471            params: params.clone(),
472            depth,
473        });
474
475        if after.is_empty() {
476            // All segments consumed
477            if !route.children.is_empty() {
478                // Has children → try to resolve index child
479                try_index_route(&route.children, depth + 1, &params, stack);
480            }
481            return true;
482        }
483
484        // More segments remain → recurse into children
485        if !route.children.is_empty()
486            && resolve_recursive(&route.children, after, depth + 1, &params, stack)
487        {
488            return true;
489        }
490
491        // No children matched (or no children) → backtrack
492        trace_log!(
493            "Backtracking from route '{}' at depth {}",
494            route_path,
495            depth
496        );
497        stack.entries.pop();
498    }
499
500    false
501}
502
503/// Try to find and push an index route (empty path or "index" path child).
504///
505/// Called when all path segments are consumed but the current route has children.
506/// This ensures navigating to `/dashboard` renders the default child.
507fn try_index_route(
508    children: &[Arc<Route>],
509    depth: usize,
510    params: &RouteParams,
511    stack: &mut MatchStack,
512) {
513    // Priority 1: Empty path child
514    for child in children {
515        let child_path = trim_slashes(&child.config.path);
516
517        if child_path.is_empty() {
518            trace_log!("Index route (empty path) resolved at depth {}", depth);
519            stack.entries.push(MatchEntry {
520                route: Arc::clone(child),
521                params: params.clone(),
522                depth,
523            });
524
525            // Recursively check if index route also has children with index
526            if !child.children.is_empty() {
527                try_index_route(&child.children, depth + 1, params, stack);
528            }
529            return;
530        }
531    }
532
533    // Priority 2: "index" named child
534    for child in children {
535        let child_path = trim_slashes(&child.config.path);
536
537        if child_path == "index" {
538            trace_log!("Index route ('index') resolved at depth {}", depth);
539            stack.entries.push(MatchEntry {
540                route: Arc::clone(child),
541                params: params.clone(),
542                depth,
543            });
544            return;
545        }
546    }
547
548    trace_log!(
549        "No index route among {} children at depth {}",
550        children.len(),
551        depth
552    );
553}
554
555// ============================================================================
556// Named Outlet Resolution
557// ============================================================================
558
559/// Resolve a named outlet at a given depth.
560///
561/// Named outlets use `Route::named_children` instead of `Route::children`.
562/// The match stack doesn't include named outlet entries — they are resolved
563/// on demand by the named outlet during rendering.
564///
565/// Returns the first matching child from the named outlet's children.
566#[must_use]
567pub fn resolve_named_outlet(
568    match_stack: &MatchStack,
569    outlet_depth: usize,
570    outlet_name: &str,
571    current_path: &str,
572) -> Option<(Arc<Route>, RouteParams)> {
573    // The parent route is at outlet_depth - 1 in the stack
574    let parent_depth = outlet_depth.checked_sub(1)?;
575    let parent_entry = match_stack.at_depth(parent_depth)?;
576
577    // Get named children for this outlet
578    let named_children = parent_entry.route.get_named_children(outlet_name)?;
579
580    if named_children.is_empty() {
581        return None;
582    }
583
584    // For named outlets, resolve against remaining path segments
585    let normalized = normalize_path(current_path);
586    let path_str = trim_slashes(&normalized);
587    let all_segments: Vec<&str> = if path_str.is_empty() {
588        vec![]
589    } else {
590        path_str.split('/').collect()
591    };
592
593    // Calculate how many segments the parent chain consumed
594    let consumed = count_consumed_segments(match_stack, parent_depth);
595    let remaining = &all_segments[consumed.min(all_segments.len())..];
596
597    // Try to match a named child
598    let params = parent_entry.params.clone();
599
600    for child in named_children {
601        let child_path = trim_slashes(&child.config.path);
602
603        if child_path.is_empty() {
604            // Index route for named outlet
605            return Some((Arc::clone(child), params));
606        }
607
608        if remaining.is_empty() {
609            continue;
610        }
611
612        // Simple single-segment match (named outlets are typically flat)
613        #[allow(clippy::redundant_clone)] // params is reused across loop iterations
614        if child_path == remaining[0] || child_path.starts_with(':') {
615            if child_path.starts_with(':') {
616                let name = child_path.trim_start_matches(':');
617                let mut child_params = params.clone();
618                child_params.insert(name.to_string(), remaining[0].to_string());
619                return Some((Arc::clone(child), child_params));
620            }
621            return Some((Arc::clone(child), params.clone()));
622        }
623    }
624
625    // Default: first child with empty path (if any)
626    for child in named_children {
627        let p = trim_slashes(&child.config.path);
628        if p.is_empty() {
629            return Some((Arc::clone(child), params));
630        }
631    }
632
633    None
634}
635
636/// Count how many path segments the match stack consumed up to a given depth.
637fn count_consumed_segments(stack: &MatchStack, up_to_depth: usize) -> usize {
638    let mut count = 0;
639    for entry in stack.entries().iter().take(up_to_depth + 1) {
640        let path = trim_slashes(&entry.route.config.path);
641        if !path.is_empty() {
642            count += path.split('/').count();
643        }
644    }
645    count
646}
647
648// Tests moved to tests/unit/resolve.rs to avoid compiler stack overflow
649// when compiling all tests in a single compilation unit.