next_core/
url_node.rs

1//! The following code is adapted from sorted-routes.ts using GPT-4 and human
2//! review.
3
4use 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        // The next segment in the urlPaths list
190        let mut next_segment = url_paths[0].to_string();
191
192        // Check if the segment matches `[something]`
193        // Strip `[` and `]`, leaving only `something`
194        let is_catch_all = if let Some(segment_name) = next_segment
195            .strip_prefix('[')
196            .and_then(|s| s.strip_suffix(']'))
197        {
198            // Strip optional `[` and `]`, leaving only `something`
199            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            // Strip `...`, leaving only `something`
209            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 the specific segment already has a slug but the slug is not `something`
231                    // This prevents collisions like:
232                    // pages/[post]/index.js
233                    // pages/[id]/index.js
234                    // Because currently multiple dynamic params on the same segment level are not
235                    // supported
236                    if previous_slug != &next_slug {
237                        // TODO: This error seems to be confusing for users, needs an error link,
238                        // the description can be based on above comment.
239                        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                    // slugName is kept as it can only be one particular slugName
285                    self.optional_rest_slug_name = Some(segment_name.to_string());
286                    // nextSegment is overwritten to [[...]] so that it can later be sorted
287                    // specifically
288                    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                    // slugName is kept as it can only be one particular slugName
299                    self.rest_slug_name = Some(segment_name.to_string());
300                    // nextSegment is overwritten to [...] so that it can later be sorted
301                    // specifically
302                    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                // slugName is kept as it can only be one particular slugName
312                self.slug_name = Some(segment_name.to_string());
313                // nextSegment is overwritten to [] so that it can later be sorted specifically
314                next_segment = "[]".to_string();
315            }
316
317            is_catch_all
318        } else {
319            is_catch_all
320        };
321
322        // If this UrlNode doesn't have the nextSegment yet we create a new child
323        // UrlNode
324        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    // First the UrlNode is created, and every UrlNode can have only 1 dynamic
338    // segment Eg you can't have pages/[post]/abc.js and
339    // pages/[hello]/something-else.js Only 1 dynamic segment per nesting level
340
341    // So in the case that is test/integration/dynamic-routing it'll be this:
342    // pages/[post]/comments.js
343    // pages/blog/[post]/comment/[id].js
344    // Both are fine because `pages/[post]` and `pages/blog` are on the same level
345    // So in this case `UrlNode` created here has `this.slugName === 'post'`
346    // And since your PR passed through `slugName` as an array basically it'd
347    // including it in too many possibilities Instead what has to be passed
348    // through is the upwards path's dynamic names
349    let mut root = UrlNode::new();
350
351    // Here the `root` gets injected multiple paths, and insert will break them up
352    // into sublevels
353    for page_path in normalized_pages {
354        root.insert(page_path)?;
355    }
356
357    // Smoosh will then sort those sublevels up to the point where you get the
358    // correct route definition priority
359    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}