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| ¶m_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, ¶ms, 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, ¶ms, 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.