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