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(¶m_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(¶m_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}