Skip to main content

next_core/next_edge/
route_regex.rs

1//! The following code was mostly generated using GTP-4 from
2//! next.js/packages/next/src/shared/lib/router/utils/route-regex.ts
3
4use std::sync::LazyLock;
5
6use regex::Regex;
7use regress;
8use rustc_hash::FxHashMap;
9
10const INTERCEPTION_ROUTE_MARKERS: [&str; 4] = ["(..)(..)", "(.)", "(..)", "(...)"];
11const NEXT_QUERY_PARAM_PREFIX: &str = "nxtP";
12const NEXT_INTERCEPTION_MARKER_PREFIX: &str = "nxtI";
13
14#[derive(Debug, Clone)]
15pub struct Group {
16    pub pos: usize,
17    pub repeat: bool,
18    pub optional: bool,
19}
20
21#[derive(Debug)]
22pub struct RouteRegex {
23    pub groups: FxHashMap<String, Group>,
24    pub regex: String,
25}
26
27#[derive(Debug)]
28pub struct NamedRouteRegex {
29    pub regex: RouteRegex,
30    pub named_regex: String,
31    pub route_keys: FxHashMap<String, String>,
32}
33
34#[derive(Debug)]
35pub struct NamedMiddlewareRegex {
36    pub named_regex: String,
37}
38
39struct ParsedParameter {
40    key: String,
41    repeat: bool,
42    optional: bool,
43}
44
45/// Parses a given parameter from a route to a data structure that can be used
46/// to generate the parametrized route. Examples:
47///   - `[...slug]` -> `{ key: 'slug', repeat: true, optional: true }`
48///   - `...slug` -> `{ key: 'slug', repeat: true, optional: false }`
49///   - `[foo]` -> `{ key: 'foo', repeat: false, optional: true }`
50///   - `bar` -> `{ key: 'bar', repeat: false, optional: false }`
51fn parse_parameter(param: &str) -> ParsedParameter {
52    let mut key = param.to_string();
53    let optional = key.starts_with('[') && key.ends_with(']');
54    if optional {
55        key = key[1..key.len() - 1].to_string();
56    }
57    let repeat = key.starts_with("...");
58    if repeat {
59        key = key[3..].to_string();
60    }
61    ParsedParameter {
62        key,
63        repeat,
64        optional,
65    }
66}
67
68fn escape_string_regexp(segment: &str) -> String {
69    regress::escape(segment)
70}
71
72/// Removes the trailing slash for a given route or page path. Preserves the
73/// root page. Examples:
74///  - `/foo/bar/` -> `/foo/bar`
75///  - `/foo/bar` -> `/foo/bar`
76///  - `/` -> `/`
77fn remove_trailing_slash(route: &str) -> &str {
78    if route == "/" {
79        route
80    } else {
81        route.trim_end_matches('/')
82    }
83}
84
85static PARAM_MATCH_REGEX: LazyLock<Regex> =
86    LazyLock::new(|| Regex::new(r"\[((?:\[.*\])|.+)\]").unwrap());
87
88fn get_parametrized_route(route: &str) -> (String, FxHashMap<String, Group>) {
89    let segments: Vec<&str> = remove_trailing_slash(route)[1..].split('/').collect();
90    let mut groups: FxHashMap<String, Group> = FxHashMap::default();
91    let mut group_index = 1;
92    let parameterized_route = segments
93        .iter()
94        .map(|segment| {
95            let marker_match = INTERCEPTION_ROUTE_MARKERS
96                .iter()
97                .find(|&&m| segment.starts_with(m))
98                .copied();
99            let param_matches = PARAM_MATCH_REGEX.captures(segment);
100            if let Some(matches) = param_matches {
101                let ParsedParameter {
102                    key,
103                    optional,
104                    repeat,
105                } = parse_parameter(&matches[1]);
106                groups.insert(
107                    key,
108                    Group {
109                        pos: group_index,
110                        repeat,
111                        optional,
112                    },
113                );
114                group_index += 1;
115                if let Some(marker) = marker_match {
116                    return format!("/{}([^/]+?)", escape_string_regexp(marker));
117                } else {
118                    return match (repeat, optional) {
119                        (true, true) => "(?:/(.+?))?",
120                        (true, false) => "/(.+?)",
121                        (false, true) => "(?:/([^/]+?))?",
122                        (false, false) => "/([^/]+?)",
123                    }
124                    .to_string();
125                }
126            }
127            format!("/{}", escape_string_regexp(segment))
128        })
129        .collect::<Vec<String>>()
130        .join("");
131    (parameterized_route, groups)
132}
133
134/// From a normalized route this function generates a regular expression and
135/// a corresponding groups object intended to be used to store matching groups
136/// from the regular expression.
137pub fn get_route_regex(normalized_route: &str) -> RouteRegex {
138    let (parameterized_route, groups) = get_parametrized_route(normalized_route);
139    RouteRegex {
140        regex: format!("^{parameterized_route}(?:/)?$"),
141        groups,
142    }
143}
144
145/// Builds a function to generate a minimal routeKey using only a-z and minimal
146/// number of characters.
147fn build_get_safe_route_key() -> impl FnMut() -> String {
148    let mut i = 0;
149
150    move || {
151        let mut route_key = String::new();
152        i += 1;
153        let mut j = i;
154
155        while j > 0 {
156            route_key.push((97 + ((j - 1) % 26)) as u8 as char);
157            j = (j - 1) / 26;
158        }
159
160        i += 1;
161        route_key
162    }
163}
164
165fn get_safe_key_from_segment(
166    get_safe_route_key: &mut impl FnMut() -> String,
167    segment: &str,
168    route_keys: &mut FxHashMap<String, String>,
169    key_prefix: Option<&'static str>,
170    intercept_prefix: Option<&str>,
171) -> String {
172    let ParsedParameter {
173        key,
174        optional,
175        repeat,
176    } = parse_parameter(segment);
177
178    // replace any non-word characters since they can break
179    // the named regex
180    let mut cleaned_key = key.replace(|c: char| !c.is_alphanumeric(), "");
181    if let Some(prefix) = key_prefix {
182        cleaned_key = format!("{prefix}{cleaned_key}");
183    }
184    let mut invalid_key = false;
185
186    // check if the key is still invalid and fallback to using a known
187    // safe key
188    if cleaned_key.is_empty() || cleaned_key.len() > 30 {
189        invalid_key = true;
190    }
191    if cleaned_key.chars().next().unwrap().is_numeric() {
192        invalid_key = true;
193    }
194    if invalid_key {
195        cleaned_key = get_safe_route_key();
196    }
197    if let Some(prefix) = key_prefix {
198        route_keys.insert(cleaned_key.clone(), format!("{prefix}{key}"));
199    } else {
200        route_keys.insert(cleaned_key.clone(), key);
201    }
202
203    let intercept_prefix = intercept_prefix.map_or_else(String::new, escape_string_regexp);
204    match (repeat, optional) {
205        (true, true) => format!(r"(?:/{intercept_prefix}(?P<{cleaned_key}>.+?))?"),
206        (true, false) => format!(r"/{intercept_prefix}(?P<{cleaned_key}>.+?)"),
207        (false, true) => format!(r"(?:/{intercept_prefix}(?P<{cleaned_key}>[^/]+?))?"),
208        (false, false) => format!(r"/{intercept_prefix}(?P<{cleaned_key}>[^/]+?)"),
209    }
210}
211
212fn get_named_parametrized_route(
213    route: &str,
214    prefix_route_keys: bool,
215) -> (String, FxHashMap<String, String>) {
216    let segments: Vec<&str> = remove_trailing_slash(route)[1..].split('/').collect();
217    let get_safe_route_key = &mut build_get_safe_route_key();
218    let mut route_keys: FxHashMap<String, String> = FxHashMap::default();
219    let parameterized_route = segments
220        .iter()
221        .map(|segment| {
222            let interception_marker = INTERCEPTION_ROUTE_MARKERS
223                .iter()
224                .find(|&m| segment.starts_with(m))
225                .copied();
226            let key_prefix = if prefix_route_keys {
227                if interception_marker.is_some() {
228                    Some(NEXT_INTERCEPTION_MARKER_PREFIX)
229                } else {
230                    Some(NEXT_QUERY_PARAM_PREFIX)
231                }
232            } else {
233                None
234            };
235            static RE: LazyLock<Regex> =
236                LazyLock::new(|| Regex::new(r"\[((?:\[.*\])|.+)\]").unwrap());
237            let param_matches = RE.captures(segment);
238            if let Some(matches) = param_matches {
239                return get_safe_key_from_segment(
240                    get_safe_route_key,
241                    &matches[1],
242                    &mut route_keys,
243                    key_prefix,
244                    interception_marker,
245                );
246            }
247            format!("/{}", escape_string_regexp(segment))
248        })
249        .collect::<Vec<String>>()
250        .join("");
251    (parameterized_route, route_keys)
252}
253
254/// Generates a named regexp.
255/// This is intended to be using for build time only.
256pub fn get_named_middleware_regex(normalized_route: &str) -> String {
257    let (parameterized_route, _route_keys) = get_named_parametrized_route(normalized_route, true);
258    format!("^{parameterized_route}(?:/)?$")
259}
260
261#[cfg(test)]
262mod test {
263    use super::get_named_middleware_regex;
264
265    #[test]
266    fn should_properly_handle_intercept_routes() {
267        let tests = [
268            (
269                "/[locale]/example/(...)[locale]/intercepted",
270                "^/(?P<nxtPlocale>[^/]+?)/example/\\(\\.\\.\\.\\)(?P<nxtIlocale>[^/]+?)/\
271                 intercepted(?:/)?$",
272            ),
273            (
274                "/[locale]/example/(..)[locale]/intercepted",
275                "^/(?P<nxtPlocale>[^/]+?)/example/\\(\\.\\.\\)(?P<nxtIlocale>[^/]+?)/intercepted(?\
276                 :/)?$",
277            ),
278            (
279                "/[locale]/example/(.)[locale]/intercepted",
280                "^/(?P<nxtPlocale>[^/]+?)/example/\\(\\.\\)(?P<nxtIlocale>[^/]+?)/intercepted(?:/\
281                 )?$",
282            ),
283            (
284                "/[locale]/example/(..)(..)[locale]/intercepted",
285                "^/(?P<nxtPlocale>[^/]+?)/example/\\(\\.\\.\\)\\(\\.\\.\\)(?P<nxtIlocale>[^/]+?)/\
286                 intercepted(?:/)?$",
287            ),
288        ];
289
290        for test in tests {
291            let intercept_route = get_named_middleware_regex(test.0);
292            assert_eq!(intercept_route, test.1);
293        }
294    }
295}