1use rustc_hash::FxHashMap;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum UrlNodeError {
9 #[error("Catch-all must be the last part of the URL.")]
10 CatchAllNotLast,
11 #[error("Segment names may not start or end with extra brackets ('{0}').")]
12 ExtraBrackets(String),
13 #[error("Segment names may not start with erroneous periods ('{0}').")]
14 ErroneousPeriod(String),
15 #[error("You cannot use different slug names for the same dynamic path ('{0}' !== '{1}').")]
16 DifferentSlugNames(String, String),
17 #[error("You cannot have the same slug name \"{0}\" repeat within a single dynamic path.")]
18 RepeatingSlugName(String),
19 #[error(
20 "You cannot have the slug names \"{0}\" and \"{1}\" differ only by non-word symbols \
21 within a single dynamic path."
22 )]
23 DifferingNonWordSymbols(String, String),
24 #[error(
25 "You cannot use both a required and optional catch-all route at the same level \
26 (\"[...{0}]\" and \"{1}\" )."
27 )]
28 RequiredAndOptionalCatchAll(String, String),
29 #[error(
30 "You cannot use both an optional and required catch-all route at the same level \
31 (\"[[...{0}]]\" and \"{1}\")."
32 )]
33 OptionalAndRequiredCatchAll(String, String),
34 #[error("Optional route parameters are not yet supported (\"{0}\").")]
35 OptionalParametersNotSupported(String),
36 #[error(
37 "You cannot define a route with the same specificity as an optional catch-all route \
38 (\"{0}\" and \"{1}[[...{2}]]\")."
39 )]
40 SameSpecificityAsOptionalCatchAll(String, String, String),
41}
42
43pub struct UrlNode {
44 placeholder: bool,
45 children: FxHashMap<String, UrlNode>,
46 slug_name: Option<String>,
47 rest_slug_name: Option<String>,
48 optional_rest_slug_name: Option<String>,
49}
50
51impl Default for UrlNode {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl UrlNode {
58 pub fn new() -> UrlNode {
59 UrlNode {
60 placeholder: true,
61 children: FxHashMap::default(),
62 slug_name: None,
63 rest_slug_name: None,
64 optional_rest_slug_name: None,
65 }
66 }
67
68 fn insert(&mut self, url_path: &str) -> Result<(), UrlNodeError> {
69 self.insert_inner(
70 &url_path
71 .split('/')
72 .filter(|s| !s.is_empty())
73 .collect::<Vec<_>>(),
74 &mut vec![],
75 false,
76 )
77 }
78
79 fn smoosh(&self) -> Result<Vec<String>, UrlNodeError> {
80 self.smoosh_with_prefix("/")
81 }
82
83 fn smoosh_with_prefix(&self, prefix: &str) -> Result<Vec<String>, UrlNodeError> {
84 let mut children_paths: Vec<_> = {
85 let mut children_paths: Vec<_> = self.children.iter().collect();
86 children_paths.sort_by_key(|(key, _)| *key);
87 children_paths
88 };
89
90 let slug_child = self.slug_name.as_ref().map(|slug_name| {
91 (
92 slug_name,
93 children_paths.remove(
94 children_paths
95 .iter()
96 .position(|(key, _)| key.as_str() == "[]")
97 .unwrap(),
98 ),
99 )
100 });
101 let rest_slug_child = self.rest_slug_name.as_ref().map(|rest_slug_name| {
102 (
103 rest_slug_name,
104 children_paths.remove(
105 children_paths
106 .iter()
107 .position(|(key, _)| key.as_str() == "[...]")
108 .unwrap(),
109 ),
110 )
111 });
112 let optional_rest_slug_child =
113 self.optional_rest_slug_name
114 .as_ref()
115 .map(|optional_rest_slug_name| {
116 (
117 optional_rest_slug_name,
118 children_paths.remove(
119 children_paths
120 .iter()
121 .position(|(key, _)| key.as_str() == "[[...]]")
122 .unwrap(),
123 ),
124 )
125 });
126
127 let mut routes = children_paths
128 .iter()
129 .map(|(key, child)| child.smoosh_with_prefix(&format!("{prefix}{key}/")))
130 .collect::<Result<Vec<_>, _>>()?
131 .into_iter()
132 .flatten()
133 .collect::<Vec<_>>();
134
135 if let Some((slug_name, (_, slug_child))) = slug_child {
136 routes.extend(slug_child.smoosh_with_prefix(&format!("{prefix}[{slug_name}]/"))?);
137 }
138
139 if !self.placeholder {
140 let r = if prefix == "/" {
141 "/".to_string()
142 } else {
143 prefix[0..prefix.len() - 1].to_string()
144 };
145 if let Some(ref optional_rest_slug_name) = self.optional_rest_slug_name {
146 return Err(UrlNodeError::SameSpecificityAsOptionalCatchAll(
147 r,
148 prefix.to_string(),
149 optional_rest_slug_name.clone(),
150 ));
151 }
152
153 routes.insert(0, r);
154 }
155
156 if let Some((rest_slug_name, (_, rest_slug_child))) = rest_slug_child {
157 routes.extend(
158 rest_slug_child.smoosh_with_prefix(&format!("{prefix}[...{rest_slug_name}]/",))?,
159 );
160 }
161
162 if let Some((optional_rest_slug_name, (_, optional_rest_slug_child))) =
163 optional_rest_slug_child
164 {
165 routes.extend(
166 optional_rest_slug_child
167 .smoosh_with_prefix(&format!("{prefix}[[...{optional_rest_slug_name}]]/",))?,
168 );
169 }
170
171 Ok(routes)
172 }
173
174 fn insert_inner(
175 &mut self,
176 url_paths: &[&str],
177 slug_names: &mut Vec<String>,
178 is_catch_all: bool,
179 ) -> Result<(), UrlNodeError> {
180 if url_paths.is_empty() {
181 self.placeholder = false;
182 return Ok(());
183 }
184
185 if is_catch_all {
186 return Err(UrlNodeError::CatchAllNotLast);
187 }
188
189 let mut next_segment = url_paths[0].to_string();
191
192 let is_catch_all = if let Some(segment_name) = next_segment
195 .strip_prefix('[')
196 .and_then(|s| s.strip_suffix(']'))
197 {
198 let (is_optional, segment_name) = if let Some(segment_name) = segment_name
200 .strip_prefix('[')
201 .and_then(|s| s.strip_suffix(']'))
202 {
203 (true, segment_name)
204 } else {
205 (false, segment_name)
206 };
207
208 let (is_catch_all, segment_name) =
210 if let Some(segment_name) = segment_name.strip_prefix("...") {
211 (true, segment_name)
212 } else {
213 (false, segment_name)
214 };
215
216 if segment_name.starts_with('[') || segment_name.ends_with(']') {
217 return Err(UrlNodeError::ExtraBrackets(segment_name.to_string()));
218 }
219
220 if segment_name.starts_with('.') {
221 return Err(UrlNodeError::ErroneousPeriod(segment_name.to_string()));
222 }
223
224 fn handle_slug(
225 previous_slug: Option<&str>,
226 next_slug: &str,
227 slug_names: &mut Vec<String>,
228 ) -> Result<(), UrlNodeError> {
229 if let Some(ref previous_slug) = previous_slug {
230 if previous_slug != &next_slug {
237 return Err(UrlNodeError::DifferentSlugNames(
240 previous_slug.to_string(),
241 next_slug.to_string(),
242 ));
243 }
244 }
245
246 for slug_name in slug_names.iter() {
247 if slug_name == next_slug {
248 return Err(UrlNodeError::RepeatingSlugName(next_slug.to_string()));
249 }
250
251 if slug_name
252 .chars()
253 .filter(|c| *c == '_' || c.is_alphanumeric())
254 .eq(next_slug
255 .chars()
256 .filter(|c| *c == '_' || c.is_alphanumeric()))
257 {
258 return Err(UrlNodeError::DifferingNonWordSymbols(
259 slug_name.clone(),
260 next_slug.to_string(),
261 ));
262 }
263 }
264
265 slug_names.push(next_slug.to_string());
266
267 Ok(())
268 }
269
270 if is_catch_all {
271 if is_optional {
272 if let Some(ref rest_slug_name) = self.rest_slug_name {
273 return Err(UrlNodeError::RequiredAndOptionalCatchAll(
274 rest_slug_name.clone(),
275 url_paths[0].to_string(),
276 ));
277 }
278
279 handle_slug(
280 self.optional_rest_slug_name.as_deref(),
281 segment_name,
282 slug_names,
283 )?;
284 self.optional_rest_slug_name = Some(segment_name.to_string());
286 next_segment = "[[...]]".to_string();
289 } else {
290 if let Some(ref optional_rest_slug_name) = self.optional_rest_slug_name {
291 return Err(UrlNodeError::OptionalAndRequiredCatchAll(
292 optional_rest_slug_name.to_string(),
293 url_paths[0].to_string(),
294 ));
295 }
296
297 handle_slug(self.rest_slug_name.as_deref(), segment_name, slug_names)?;
298 self.rest_slug_name = Some(segment_name.to_string());
300 next_segment = "[...]".to_string();
303 }
304 } else {
305 if is_optional {
306 return Err(UrlNodeError::OptionalParametersNotSupported(
307 url_paths[0].to_string(),
308 ));
309 }
310 handle_slug(self.slug_name.as_deref(), segment_name, slug_names)?;
311 self.slug_name = Some(segment_name.to_string());
313 next_segment = "[]".to_string();
315 }
316
317 is_catch_all
318 } else {
319 is_catch_all
320 };
321
322 if !self.children.contains_key(&next_segment) {
325 self.children.insert(next_segment.clone(), UrlNode::new());
326 }
327
328 self.children.get_mut(&next_segment).unwrap().insert_inner(
329 &url_paths[1..],
330 slug_names,
331 is_catch_all,
332 )
333 }
334}
335
336pub fn get_sorted_routes(normalized_pages: &[String]) -> Result<Vec<String>, UrlNodeError> {
337 let mut root = UrlNode::new();
350
351 for page_path in normalized_pages {
354 root.insert(page_path)?;
355 }
356
357 root.smoosh()
360}
361
362#[cfg(test)]
363mod tests {
364 use super::get_sorted_routes;
365
366 #[test]
367 fn does_not_add_extra_routes() {
368 assert_eq!(
369 get_sorted_routes(&["/posts".to_string()]).unwrap(),
370 vec!["/posts"]
371 );
372
373 assert_eq!(
374 get_sorted_routes(&["/posts/[id]".to_string()]).unwrap(),
375 vec!["/posts/[id]"]
376 );
377
378 assert_eq!(
379 get_sorted_routes(&["/posts/[id]/foo".to_string()]).unwrap(),
380 vec!["/posts/[id]/foo"]
381 );
382
383 assert_eq!(
384 get_sorted_routes(&["/posts/[id]/[foo]/bar".to_string()]).unwrap(),
385 vec!["/posts/[id]/[foo]/bar"]
386 );
387
388 assert_eq!(
389 get_sorted_routes(&["/posts/[id]/baz/[foo]/bar".to_string()]).unwrap(),
390 vec!["/posts/[id]/baz/[foo]/bar"]
391 );
392 }
393
394 #[test]
395 fn correctly_sorts_required_slugs() {
396 let sorted_routes = get_sorted_routes(&[
397 "/posts".to_string(),
398 "/[root-slug]".to_string(),
399 "/".to_string(),
400 "/posts/[id]".to_string(),
401 "/blog/[id]/comments/[cid]".to_string(),
402 "/blog/abc/[id]".to_string(),
403 "/[...rest]".to_string(),
404 "/blog/abc/post".to_string(),
405 "/blog/abc".to_string(),
406 "/p1/[[...incl]]".to_string(),
407 "/p/[...rest]".to_string(),
408 "/p2/[...rest]".to_string(),
409 "/p2/[id]".to_string(),
410 "/p2/[id]/abc".to_string(),
411 "/p3/[[...rest]]".to_string(),
412 "/p3/[id]".to_string(),
413 "/p3/[id]/abc".to_string(),
414 "/blog/[id]".to_string(),
415 "/foo/[d]/bar/baz/[f]".to_string(),
416 "/apples/[ab]/[cd]/ef".to_string(),
417 ])
418 .unwrap();
419
420 assert_eq!(
421 sorted_routes,
422 vec![
423 "/",
424 "/apples/[ab]/[cd]/ef",
425 "/blog/abc",
426 "/blog/abc/post",
427 "/blog/abc/[id]",
428 "/blog/[id]",
429 "/blog/[id]/comments/[cid]",
430 "/foo/[d]/bar/baz/[f]",
431 "/p/[...rest]",
432 "/p1/[[...incl]]",
433 "/p2/[id]",
434 "/p2/[id]/abc",
435 "/p2/[...rest]",
436 "/p3/[id]",
437 "/p3/[id]/abc",
438 "/p3/[[...rest]]",
439 "/posts",
440 "/posts/[id]",
441 "/[root-slug]",
442 "/[...rest]",
443 ]
444 );
445 }
446
447 #[test]
448 fn catches_mismatched_param_names() {
449 let result = get_sorted_routes(&[
450 "/".to_string(),
451 "/blog".to_string(),
452 "/blog/[id]".to_string(),
453 "/blog/[id]/comments/[cid]".to_string(),
454 "/blog/[cid]".to_string(),
455 ]);
456 assert!(result.is_err());
457 assert!(
458 result
459 .unwrap_err()
460 .to_string()
461 .contains("different slug names")
462 );
463 }
464
465 #[test]
466 fn catches_reused_param_names() {
467 let result = get_sorted_routes(&[
468 "/".to_string(),
469 "/blog".to_string(),
470 "/blog/[id]/comments/[id]".to_string(),
471 "/blog/[id]".to_string(),
472 ]);
473 assert!(result.is_err());
474 assert!(
475 result
476 .unwrap_err()
477 .to_string()
478 .contains("the same slug name")
479 );
480 }
481
482 #[test]
483 fn catches_reused_param_names_with_catch_all() {
484 let result =
485 get_sorted_routes(&["/blog/[id]".to_string(), "/blog/[id]/[...id]".to_string()]);
486 assert!(result.is_err());
487 assert!(
488 result
489 .unwrap_err()
490 .to_string()
491 .contains("the same slug name")
492 );
493 }
494
495 #[test]
496 fn catches_middle_catch_all_with_another_catch_all() {
497 let result = get_sorted_routes(&["/blog/[...id]/[...id2]".to_string()]);
498 assert!(result.is_err());
499 assert!(
500 result
501 .unwrap_err()
502 .to_string()
503 .contains("Catch-all must be the last part of the URL.")
504 );
505 }
506
507 #[test]
508 fn catches_middle_catch_all_with_fixed_route() {
509 let result = get_sorted_routes(&["/blog/[...id]/abc".to_string()]);
510 assert!(result.is_err());
511 assert!(
512 result
513 .unwrap_err()
514 .to_string()
515 .contains("Catch-all must be the last part of the URL.")
516 );
517 }
518
519 #[test]
520 fn catches_extra_dots_in_catch_all() {
521 let result = get_sorted_routes(&["/blog/[....id]/abc".to_string()]);
522 assert!(result.is_err());
523 assert!(
524 result
525 .unwrap_err()
526 .to_string()
527 .contains("Segment names may not start with erroneous periods")
528 );
529 }
530
531 #[test]
532 fn catches_missing_dots_in_catch_all() {
533 let result = get_sorted_routes(&["/blog/[..id]/abc".to_string()]);
534 assert!(result.is_err());
535 assert!(
536 result
537 .unwrap_err()
538 .to_string()
539 .contains("Segment names may not start with erroneous periods")
540 );
541 }
542
543 #[test]
544 fn catches_extra_brackets_for_optional_1() {
545 let result = get_sorted_routes(&["/blog/[[...id]".to_string()]);
546 assert!(result.is_err());
547 assert!(
548 result
549 .unwrap_err()
550 .to_string()
551 .contains("Segment names may not start or end with extra brackets")
552 );
553 }
554
555 #[test]
556 fn catches_extra_brackets_for_optional_2() {
557 let result = get_sorted_routes(&["/blog/[[[...id]]".to_string()]);
558 assert!(result.is_err());
559 assert_eq!(
560 result.unwrap_err().to_string(),
561 "Segment names may not start or end with extra brackets ('[...id')."
562 );
563 }
564
565 #[test]
566 fn catches_extra_brackets_for_optional_3() {
567 let result = get_sorted_routes(&["/blog/[...id]]".to_string()]);
568 assert!(result.is_err());
569 assert_eq!(
570 result.unwrap_err().to_string(),
571 "Segment names may not start or end with extra brackets ('id]')."
572 );
573 }
574
575 #[test]
576 fn catches_extra_brackets_for_optional_4() {
577 let result = get_sorted_routes(&["/blog/[[...id]]]".to_string()]);
578 assert!(result.is_err());
579 assert_eq!(
580 result.unwrap_err().to_string(),
581 "Segment names may not start or end with extra brackets ('id]')."
582 );
583 }
584
585 #[test]
586 fn catches_extra_brackets_for_optional_5() {
587 let result = get_sorted_routes(&["/blog/[[[...id]]]".to_string()]);
588 assert!(result.is_err());
589 assert_eq!(
590 result.unwrap_err().to_string(),
591 "Segment names may not start or end with extra brackets ('[...id]')."
592 );
593 }
594
595 #[test]
596 fn disallows_optional_params_1() {
597 let result = get_sorted_routes(&["/[[blog]]".to_string()]);
598 assert!(result.is_err());
599 assert_eq!(
600 result.unwrap_err().to_string(),
601 "Optional route parameters are not yet supported (\"[[blog]]\")."
602 );
603 }
604
605 #[test]
606 fn disallows_optional_params_2() {
607 let result = get_sorted_routes(&["/abc/[[blog]]".to_string()]);
608 assert!(result.is_err());
609 assert_eq!(
610 result.unwrap_err().to_string(),
611 "Optional route parameters are not yet supported (\"[[blog]]\")."
612 );
613 }
614
615 #[test]
616 fn disallows_optional_params_3() {
617 let result = get_sorted_routes(&["/abc/[[blog]]/def".to_string()]);
618 assert!(result.is_err());
619 assert_eq!(
620 result.unwrap_err().to_string(),
621 "Optional route parameters are not yet supported (\"[[blog]]\")."
622 );
623 }
624
625 #[test]
626 fn disallows_mixing_required_catch_all_and_optional_catch_all_1() {
627 let result = get_sorted_routes(&["/[...one]".to_string(), "/[[...one]]".to_string()]);
628 assert!(result.is_err());
629 assert_eq!(
630 result.unwrap_err().to_string(),
631 "You cannot use both a required and optional catch-all route at the same level \
632 (\"[...one]\" and \"[[...one]]\" )."
633 );
634 }
635
636 #[test]
637 fn disallows_mixing_required_catch_all_and_optional_catch_all_2() {
638 let result = get_sorted_routes(&["/[[...one]]".to_string(), "/[...one]".to_string()]);
639 assert!(result.is_err());
640 assert_eq!(
641 result.unwrap_err().to_string(),
642 "You cannot use both an optional and required catch-all route at the same level \
643 (\"[[...one]]\" and \"[...one]\")."
644 );
645 }
646
647 #[test]
648 fn disallows_apex_and_optional_catch_all() {
649 let result = get_sorted_routes(&["/".to_string(), "/[[...all]]".to_string()]);
650 assert!(result.is_err());
651 assert_eq!(
652 result.unwrap_err().to_string(),
653 "You cannot define a route with the same specificity as an optional catch-all route \
654 (\"/\" and \"/[[...all]]\")."
655 );
656 }
657
658 #[test]
659 fn disallows_apex_and_optional_catch_all_2() {
660 let result = get_sorted_routes(&["/[[...all]]".to_string(), "/".to_string()]);
661 assert!(result.is_err());
662 assert_eq!(
663 result.unwrap_err().to_string(),
664 "You cannot define a route with the same specificity as an optional catch-all route \
665 (\"/\" and \"/[[...all]]\")."
666 );
667 }
668
669 #[test]
670 fn disallows_apex_and_optional_catch_all_3() {
671 let result = get_sorted_routes(&["/sub".to_string(), "/sub/[[...all]]".to_string()]);
672 assert!(result.is_err());
673 assert_eq!(
674 result.unwrap_err().to_string(),
675 "You cannot define a route with the same specificity as an optional catch-all route \
676 (\"/sub\" and \"/sub/[[...all]]\")."
677 );
678 }
679
680 #[test]
681 fn disallows_apex_and_optional_catch_all_4() {
682 let result = get_sorted_routes(&["/sub/[[...all]]".to_string(), "/sub".to_string()]);
683 assert!(result.is_err());
684 assert_eq!(
685 result.unwrap_err().to_string(),
686 "You cannot define a route with the same specificity as an optional catch-all route \
687 (\"/sub\" and \"/sub/[[...all]]\")."
688 );
689 }
690
691 #[test]
692 fn catches_param_names_differing_only_by_non_word_characters() {
693 let result = get_sorted_routes(&[
694 "/blog/[helloworld]".to_string(),
695 "/blog/[helloworld]/[hello-world]".to_string(),
696 ]);
697 assert!(result.is_err());
698 assert!(
699 result
700 .unwrap_err()
701 .to_string()
702 .contains("differ only by non-word")
703 );
704 }
705}