Skip to main content

gpui_navigator/
nested.rs

1//! Nested route resolution
2//!
3//! This module provides functionality for resolving child routes in nested routing scenarios.
4//! The cache functionality has been moved to the `cache` module (available with `cache` feature).
5//!
6//! # Path Normalization (T053)
7//!
8//! All path operations in this module use consistent normalization to handle various path formats:
9//!
10//! ## Normalization Rules
11//!
12//! 1. **Empty paths** are normalized to `"/"` (root)
13//! 2. **Leading slashes** are ensured (e.g., `"dashboard"` → `"/dashboard"`)
14//! 3. **Trailing slashes** are removed (except for root: `"/"`)
15//! 4. **Multiple slashes** are collapsed to single slash (e.g., `"//dashboard"` → `"/dashboard"`)
16//! 5. **Root variations** (`"/"`, `"//"`, `""`) all normalize to `"/"`
17//!
18//! ## Examples
19//!
20//! ```ignore
21//! // All these paths resolve to the same route:
22//! navigate("/dashboard");
23//! navigate("dashboard");
24//! navigate("/dashboard/");
25//! navigate("//dashboard");
26//!
27//! // Root path variations:
28//! navigate("/");     // Root
29//! navigate("");      // Also root
30//! navigate("//");    // Also root
31//! ```
32//!
33//! ## Implementation
34//!
35//! Path normalization is performed by the [`normalize_path()`] function, which returns
36//! `Cow<str>` to avoid allocations when paths are already normalized. This is critical
37//! for performance in hot paths like route resolution.
38
39use crate::route::Route;
40use crate::{trace_log, warn_log, RouteParams};
41use std::borrow::Cow;
42use std::sync::Arc;
43
44/// Strip leading and trailing slashes from a route path segment.
45///
46/// This pattern appears throughout the codebase. Centralizing it ensures
47/// consistency and makes call sites more readable.
48#[inline]
49pub(crate) fn trim_slashes(path: &str) -> &str {
50    path.trim_start_matches('/').trim_end_matches('/')
51}
52
53/// Maximum recursion depth for nested routes (T031 - User Story 3)
54///
55/// Prevents infinite loops and stack overflow in deeply nested route hierarchies.
56/// Configured for 10 levels to support complex applications while maintaining safety.
57const MAX_RECURSION_DEPTH: usize = 10;
58
59/// Resolved child route information
60///
61/// Contains the matched child route and merged parameters from parent and child.
62pub type ResolvedChildRoute = (Arc<Route>, RouteParams);
63
64/// Normalize a path for consistent comparison
65///
66/// Ensures paths have a leading slash and no trailing slash (unless root).
67/// Returns `Cow<str>` to avoid allocation when path is already normalized.
68///
69/// # Examples
70///
71/// ```
72/// use gpui_navigator::normalize_path;
73///
74/// assert_eq!(normalize_path("/dashboard"), "/dashboard");
75/// assert_eq!(normalize_path("dashboard"), "/dashboard");
76/// assert_eq!(normalize_path("/dashboard/"), "/dashboard");
77/// assert_eq!(normalize_path("/"), "/");
78/// assert_eq!(normalize_path(""), "/");
79/// ```
80#[must_use]
81pub fn normalize_path(path: &'_ str) -> Cow<'_, str> {
82    // Handle empty path -> "/"
83    if path.is_empty() {
84        return Cow::Borrowed("/");
85    }
86
87    // Handle already-normalized root
88    if path == "/" {
89        return Cow::Borrowed(path);
90    }
91
92    let has_leading = path.starts_with('/');
93    let has_trailing = path.ends_with('/');
94
95    // Already normalized: has leading, no trailing
96    if has_leading && !has_trailing {
97        return Cow::Borrowed(path);
98    }
99
100    // Need to normalize
101    let trimmed = path.trim_matches('/');
102    if trimmed.is_empty() {
103        Cow::Borrowed("/")
104    } else {
105        Cow::Owned(format!("/{trimmed}"))
106    }
107}
108
109/// Extract parameter name from a route path segment
110///
111/// Strips leading ':' and any type constraints like `:id<i32>` -> `id`.
112/// Returns `Cow<str>` to avoid allocation when no constraint exists.
113///
114/// # Examples
115///
116/// ```
117/// use gpui_navigator::extract_param_name;
118///
119/// assert_eq!(extract_param_name(":id"), "id");
120/// assert_eq!(extract_param_name(":id<i32>"), "id");
121/// assert_eq!(extract_param_name(":user_id<uuid>"), "user_id");
122/// ```
123#[must_use]
124pub fn extract_param_name(segment: &'_ str) -> Cow<'_, str> {
125    let without_colon = segment.trim_start_matches(':');
126
127    // Check for constraint delimiter '<'
128    without_colon.find('<').map_or_else(
129        || Cow::Borrowed(without_colon),
130        |pos| Cow::Owned(without_colon[..pos].to_string()),
131    )
132}
133
134/// Resolve a child route with recursion depth tracking (T031)
135///
136/// Public wrapper that starts recursion depth tracking at 0.
137/// Use this function for all external calls.
138#[must_use]
139pub fn resolve_child_route(
140    parent_route: &Arc<Route>,
141    current_path: &str,
142    parent_params: &RouteParams,
143    outlet_name: Option<&str>,
144) -> Option<ResolvedChildRoute> {
145    resolve_child_route_impl(parent_route, current_path, parent_params, outlet_name, 0)
146}
147
148/// Internal implementation with recursion depth tracking (T031)
149///
150/// Prevents infinite loops by enforcing `MAX_RECURSION_DEPTH` limit.
151/// Returns None if depth exceeded.
152#[allow(clippy::too_many_lines)]
153fn resolve_child_route_impl(
154    parent_route: &Arc<Route>,
155    current_path: &str,
156    parent_params: &RouteParams,
157    outlet_name: Option<&str>,
158    depth: usize,
159) -> Option<ResolvedChildRoute> {
160    // T031: Check recursion depth limit
161    if depth >= MAX_RECURSION_DEPTH {
162        warn_log!(
163            "Maximum recursion depth ({}) exceeded while resolving path '{}' from parent '{}'",
164            MAX_RECURSION_DEPTH,
165            current_path,
166            parent_route.config.path
167        );
168        return None;
169    }
170    // T051: Explicit check for empty current path - normalize to root
171    let normalized_current = if current_path.is_empty() {
172        "/"
173    } else {
174        current_path
175    };
176
177    // T050: Explicit check for root path - should match index route
178    let is_root_path = normalized_current == "/";
179
180    trace_log!(
181        "resolve_child_route: parent='{}', current_path='{}' (normalized='{}', is_root={}), children={}, outlet_name={:?}",
182        parent_route.config.path,
183        current_path,
184        normalized_current,
185        is_root_path,
186        parent_route.get_children().len(),
187        outlet_name
188    );
189    // Get the children for this outlet (named or default)
190    let children = if let Some(name) = outlet_name {
191        // Named outlet - get children from named_children map
192        if let Some(named_children) = parent_route.get_named_children(name) {
193            trace_log!(
194                "Using named outlet '{}' with {} children",
195                name,
196                named_children.len()
197            );
198            named_children
199        } else {
200            // T060: Improved error message with available outlets list
201            let available_outlets: Vec<&str> = parent_route.named_outlet_names();
202            if available_outlets.is_empty() {
203                warn_log!(
204                    "Named outlet '{}' not found in route '{}'. No named outlets are defined for this route.",
205                    name,
206                    parent_route.config.path
207                );
208            } else {
209                warn_log!(
210                    "Named outlet '{}' not found in route '{}'. Available named outlets: {:?}",
211                    name,
212                    parent_route.config.path,
213                    available_outlets
214                );
215            }
216            return None;
217        }
218    } else {
219        // Default outlet - use regular children
220        parent_route.get_children()
221    };
222
223    if children.is_empty() {
224        trace_log!("No children found for outlet {:?}", outlet_name);
225        return None;
226    }
227
228    // Strip slashes for comparison — avoids repeated normalize_path allocations
229    let parent_trimmed = trim_slashes(&parent_route.config.path);
230    let current_trimmed = trim_slashes(normalized_current);
231
232    // Extract the remaining path after stripping the parent prefix
233    let remaining = if parent_trimmed.starts_with(':') {
234        // Parameter route — no static prefix to strip
235        current_trimmed
236    } else if parent_trimmed.is_empty() {
237        // Root parent — entire current path is the remainder
238        current_trimmed
239    } else if let Some(rest) = current_trimmed.strip_prefix(parent_trimmed) {
240        // Static parent — strip its prefix and any leading slash
241        rest.trim_start_matches('/')
242    } else {
243        // Current path doesn't start with parent — no match
244        return None;
245    };
246
247    trace_log!(
248        "  parent_trimmed='{}', current_trimmed='{}', remaining='{}'",
249        parent_trimmed,
250        current_trimmed,
251        remaining
252    );
253
254    if remaining.is_empty() {
255        // No child path, look for index route
256        return find_index_route(children, parent_params.clone());
257    }
258
259    // Split remaining path into segments
260    let segments: Vec<&str> = remaining.split('/').filter(|s| !s.is_empty()).collect();
261    if segments.is_empty() {
262        return find_index_route(children, parent_params.clone());
263    }
264
265    let first_segment = segments[0];
266    trace_log!("  first_segment: '{}'", first_segment);
267
268    // Try to match first segment against child routes
269    for child in children {
270        let child_path = child.config.path.trim_start_matches('/');
271
272        // Check for exact match or parameter match
273        if child_path == first_segment || child_path.starts_with(':') {
274            trace_log!("  matched: '{}'", child_path);
275            // Found matching child!
276            let mut combined_params = parent_params.clone();
277
278            // If this is a parameter route, extract the parameter (BUG-003: use extract_param_name)
279            if child_path.starts_with(':') {
280                let param_name = extract_param_name(child_path);
281
282                // T047: Warn on parameter collision (debug mode)
283                #[cfg(debug_assertions)]
284                if parent_params.contains(&param_name) {
285                    warn_log!(
286                        "Parameter collision: child route '{}' shadows parent parameter '{}' (parent value: '{}', child value: '{}')",
287                        child.config.path,
288                        param_name,
289                        parent_params.get(&param_name).map_or("<none>", String::as_str),
290                        first_segment
291                    );
292                }
293
294                combined_params.insert(param_name.to_string(), first_segment.to_string());
295            }
296
297            // BUG-002: Handle nested parameters in deeper child paths (recursive resolution)
298            if segments.len() > 1 {
299                // More segments remaining - recursively resolve deeper levels
300                trace_log!("  recursing for remaining {} segments", segments.len() - 1);
301
302                // Construct path for recursive call:
303                // - For parameter routes: pass remaining segments only (parameter has no prefix)
304                // - For static routes: include the matched segment (so it can strip its own prefix)
305                let remaining_path = if child_path.starts_with(':') {
306                    // Parameter route - pass segments after the matched one
307                    format!("/{}", segments[1..].join("/"))
308                } else {
309                    // Static route - include the matched segment so it can strip its prefix
310                    format!("/{}", segments.join("/"))
311                };
312                trace_log!("  remaining_path for recursion: '{}'", remaining_path);
313
314                // Recursively resolve the child route with the remaining path (T031: pass depth + 1)
315                if let Some((grandchild, grandchild_params)) = resolve_child_route_impl(
316                    child,
317                    &remaining_path,
318                    &combined_params,
319                    outlet_name,
320                    depth + 1,
321                ) {
322                    return Some((grandchild, grandchild_params));
323                }
324                // If recursive resolution fails, continue to next child
325                continue;
326            }
327
328            return Some((Arc::clone(child), combined_params));
329        }
330    }
331
332    None
333}
334
335/// Find an index route (default child route when no specific child is selected)
336///
337/// T037: Prioritize index routes (empty path "") when no exact child match.
338/// An index route serves as the default content when navigating to a parent path.
339///
340/// # Priority Order
341///
342/// 1. Empty path ("") - highest priority, explicit index route
343/// 2. Path "index" - alternative naming convention
344///
345/// # Examples
346///
347/// ```ignore
348/// // Define index route
349/// Route::new("/dashboard", |_, _, _| {
350///     div().child(render_router_outlet(...))
351/// })
352/// .children(vec![
353///     Route::new("", |_, _, _| div().child("Overview")).into(),  // Index route
354///     Route::new("settings", |_, _, _| div().child("Settings")).into(),
355/// ]);
356///
357/// // Navigate to "/dashboard" → renders Overview (index route)
358/// // Navigate to "/dashboard/settings" → renders Settings
359/// ```
360fn find_index_route(children: &[Arc<Route>], params: RouteParams) -> Option<ResolvedChildRoute> {
361    trace_log!("find_index_route: searching {} children", children.len());
362
363    // T037: Prioritize index routes
364    // Single pass: check both empty path (priority 1) and "index" (priority 2)
365    let mut index_fallback: Option<&Arc<Route>> = None;
366
367    for child in children {
368        let child_path = child.config.path.trim_matches('/');
369
370        if child_path.is_empty() {
371            trace_log!(
372                "find_index_route: found index route with empty path '{}'",
373                child.config.path
374            );
375            return Some((Arc::clone(child), params));
376        }
377
378        if child_path == "index" && index_fallback.is_none() {
379            index_fallback = Some(child);
380        }
381    }
382
383    if let Some(child) = index_fallback {
384        trace_log!(
385            "find_index_route: found index route with path 'index' (original: '{}')",
386            child.config.path
387        );
388        return Some((Arc::clone(child), params));
389    }
390
391    trace_log!(
392        "find_index_route: no index route found among {} children",
393        children.len()
394    );
395    None
396}
397
398/// Build the full path for a child route
399///
400/// Combines parent and child paths into a complete route path.
401///
402/// Returns `Cow<str>` to avoid unnecessary allocations when possible.
403/// Uses borrowed string when no modification is needed.
404///
405/// # Example
406///
407/// ```
408/// use gpui_navigator::build_child_path;
409///
410/// let full_path = build_child_path("/dashboard", "settings");
411/// assert_eq!(full_path, "/dashboard/settings");
412/// ```
413#[must_use]
414pub fn build_child_path<'a>(parent_path: &'a str, child_path: &'a str) -> Cow<'a, str> {
415    // CRITICAL: Don't normalize empty child paths - they represent index routes
416    // Normalizing "" to "/" would make the child have the same path as parent, causing infinite recursion
417
418    // For empty child path (index route), return the parent path as-is
419    if child_path.is_empty() {
420        return normalize_path(parent_path);
421    }
422
423    // For non-empty paths, normalize them
424    let parent_normalized = normalize_path(parent_path);
425    let child_normalized = normalize_path(child_path);
426
427    let parent = parent_normalized.trim_matches('/');
428    let child = child_normalized.trim_matches('/');
429
430    if parent.is_empty() {
431        // T052: Root path handling - when parent is root ("/"), child becomes the full path
432        child_normalized
433    } else {
434        // Combine parent and child
435        Cow::Owned(format!("/{parent}/{child}"))
436    }
437}