use std::path::Path;
const ROUTE_EXTENSIONS: &[&str] = &[
"ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "mdx", "md",
];
const URL_LEAF_STEMS: &[&str] = &["page", "route"];
const DECORATOR_STEMS: &[&str] = &[
"layout",
"template",
"loading",
"error",
"not-found",
"default",
"global-error",
"global-not-found",
"forbidden",
"unauthorized",
"icon",
"apple-icon",
"opengraph-image",
"twitter-image",
"manifest",
"sitemap",
"robots",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteRole {
UrlLeaf,
Decorator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DynKind {
Required,
CatchAll,
OptionalCatchAll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteSegment<'a> {
Group(&'a str),
Slot(&'a str),
Dynamic { raw: &'a str, kind: DynKind },
Literal(&'a str),
}
#[derive(Debug, Clone)]
pub struct ClassifiedRoute<'a> {
pub app_root: String,
pub role: RouteRole,
pub segments: Vec<RouteSegment<'a>>,
}
#[derive(Debug, Clone)]
pub struct DynamicOccurrence {
pub slot_key: String,
pub position: String,
pub spelling: String,
}
impl ClassifiedRoute<'_> {
#[must_use]
pub const fn is_url_leaf(&self) -> bool {
matches!(self.role, RouteRole::UrlLeaf)
}
#[must_use]
pub fn collision_bucket(&self) -> (String, String, String) {
let mut slots: Vec<&str> = Vec::new();
let mut url_parts: Vec<&str> = Vec::new();
for seg in &self.segments {
match seg {
RouteSegment::Group(_) => {}
RouteSegment::Slot(name) => slots.push(name),
RouteSegment::Dynamic { raw, .. } => url_parts.push(raw),
RouteSegment::Literal(name) => url_parts.push(name),
}
}
(
self.app_root.clone(),
join_slot_key(&slots),
join_url(&url_parts),
)
}
#[must_use]
pub fn dynamic_occurrences(&self) -> Vec<DynamicOccurrence> {
let mut slots: Vec<&str> = Vec::new();
let mut url_parts: Vec<&str> = Vec::new();
let mut out = Vec::new();
for seg in &self.segments {
match seg {
RouteSegment::Group(_) => {}
RouteSegment::Slot(name) => slots.push(name),
RouteSegment::Dynamic { raw, .. } => {
out.push(DynamicOccurrence {
slot_key: join_slot_key(&slots),
position: join_url(&url_parts),
spelling: (*raw).to_string(),
});
url_parts.push(raw);
}
RouteSegment::Literal(name) => url_parts.push(name),
}
}
out
}
}
fn join_slot_key(slots: &[&str]) -> String {
slots.join("/")
}
fn join_url(parts: &[&str]) -> String {
if parts.is_empty() {
"/".to_string()
} else {
format!("/{}", parts.join("/"))
}
}
#[must_use]
pub fn classify_route_file<'a>(path: &'a Path, pkg_roots: &[&Path]) -> Option<ClassifiedRoute<'a>> {
let pkg_root = pkg_roots
.iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())?;
let rel = path.strip_prefix(pkg_root).ok()?;
let comps: Vec<&str> = rel
.components()
.filter_map(|c| match c {
std::path::Component::Normal(os) => os.to_str(),
_ => None,
})
.collect();
let (app_root_suffix, first_seg_idx) = match comps.split_first() {
Some((&"app", _)) => ("app", 1),
Some((&"src", rest)) if rest.first() == Some(&"app") => ("src/app", 2),
_ => return None,
};
if comps.len() <= first_seg_idx {
return None;
}
let filename = *comps.last()?;
let role = classify_filename(filename)?;
let dir_segments = &comps[first_seg_idx..comps.len() - 1];
let mut segments = Vec::with_capacity(dir_segments.len());
for &seg in dir_segments {
if is_private(seg) || is_intercepting(seg) {
return None;
}
segments.push(classify_segment(seg));
}
let app_root = format!("{}/{app_root_suffix}", pkg_root.display());
Some(ClassifiedRoute {
app_root,
role,
segments,
})
}
fn classify_filename(filename: &str) -> Option<RouteRole> {
let (stem, ext) = filename.rsplit_once('.')?;
if !ROUTE_EXTENSIONS.contains(&ext) {
return None;
}
if URL_LEAF_STEMS.contains(&stem) {
Some(RouteRole::UrlLeaf)
} else if DECORATOR_STEMS.contains(&stem) {
Some(RouteRole::Decorator)
} else {
None
}
}
fn is_private(seg: &str) -> bool {
seg.starts_with('_')
}
fn is_intercepting(seg: &str) -> bool {
seg.starts_with("(.)") || seg.starts_with("(..)") || seg.starts_with("(...)")
}
fn classify_segment(seg: &str) -> RouteSegment<'_> {
if let Some(name) = seg.strip_prefix('@') {
return RouteSegment::Slot(name);
}
if seg.starts_with('[') && seg.ends_with(']') {
let kind = if seg.starts_with("[[...") {
DynKind::OptionalCatchAll
} else if seg.starts_with("[...") {
DynKind::CatchAll
} else {
DynKind::Required
};
return RouteSegment::Dynamic { raw: seg, kind };
}
if seg.starts_with('(') && seg.ends_with(')') {
return RouteSegment::Group(&seg[1..seg.len() - 1]);
}
RouteSegment::Literal(seg)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn pkg(root: &str) -> PathBuf {
PathBuf::from(root)
}
fn classify<'a>(path: &'a Path, roots: &[&Path]) -> Option<ClassifiedRoute<'a>> {
classify_route_file(path, roots)
}
#[test]
fn route_group_pages_share_url_within_one_app_root() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let a = PathBuf::from("/repo/app/(marketing)/about/page.tsx");
let b = PathBuf::from("/repo/app/(shop)/about/page.tsx");
let ca = classify(&a, &roots).unwrap();
let cb = classify(&b, &roots).unwrap();
assert!(ca.is_url_leaf() && cb.is_url_leaf());
assert_eq!(ca.collision_bucket(), cb.collision_bucket());
assert_eq!(ca.collision_bucket().2, "/about");
}
#[test]
fn src_app_prefix_is_recognized() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let p = PathBuf::from("/repo/src/app/blog/page.tsx");
let c = classify(&p, &roots).unwrap();
assert_eq!(c.app_root, "/repo/src/app");
assert_eq!(c.collision_bucket().2, "/blog");
}
#[test]
fn parallel_slots_do_not_share_collision_bucket() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let a = PathBuf::from("/repo/app/@team/members/page.tsx");
let b = PathBuf::from("/repo/app/@analytics/members/page.tsx");
let ca = classify(&a, &roots).unwrap();
let cb = classify(&b, &roots).unwrap();
assert_eq!(ca.collision_bucket().2, "/members");
assert_eq!(cb.collision_bucket().2, "/members");
assert_ne!(ca.collision_bucket().1, cb.collision_bucket().1);
}
#[test]
fn monorepo_two_apps_have_distinct_app_roots() {
let web = pkg("/repo/apps/web");
let admin = pkg("/repo/apps/admin");
let roots: Vec<&Path> = vec![web.as_path(), admin.as_path()];
let a = PathBuf::from("/repo/apps/web/app/about/page.tsx");
let b = PathBuf::from("/repo/apps/admin/app/about/page.tsx");
let ca = classify(&a, &roots).unwrap();
let cb = classify(&b, &roots).unwrap();
assert_ne!(ca.collision_bucket().0, cb.collision_bucket().0);
}
#[test]
fn library_app_folder_is_not_an_app_root() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let p = PathBuf::from("/repo/libs/feature-shell/app/widget.ts");
assert!(classify(&p, &roots).is_none());
}
#[test]
fn private_folder_excluded() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let p = PathBuf::from("/repo/app/_components/page.tsx");
assert!(classify(&p, &roots).is_none());
}
#[test]
fn intercepting_marker_excluded() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
for seg in ["(.)photo", "(..)photo", "(...)photo"] {
let p = PathBuf::from(format!("/repo/app/feed/{seg}/[id]/page.tsx"));
assert!(classify(&p, &roots).is_none(), "should exclude {seg}");
}
}
#[test]
fn colocated_non_route_files_rejected() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
for name in ["page.test.tsx", "page.module.css", "helpers.ts", "page.css"] {
let p = PathBuf::from(format!("/repo/app/about/{name}"));
assert!(classify(&p, &roots).is_none(), "should reject {name}");
}
}
#[test]
fn mdx_route_recognized() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let p = PathBuf::from("/repo/app/docs/page.mdx");
assert!(classify(&p, &roots).unwrap().is_url_leaf());
}
#[test]
fn page_and_route_share_url_owner_namespace() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let page = PathBuf::from("/repo/app/(a)/about/page.tsx");
let route = PathBuf::from("/repo/app/(b)/about/route.ts");
let cp = classify(&page, &roots).unwrap();
let cr = classify(&route, &roots).unwrap();
assert!(cp.is_url_leaf() && cr.is_url_leaf());
assert_eq!(cp.collision_bucket(), cr.collision_bucket());
}
#[test]
fn dynamic_names_kept_distinct_in_collision_bucket() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let id = PathBuf::from("/repo/app/(a)/[id]/page.tsx");
let slug = PathBuf::from("/repo/app/(b)/[slug]/page.tsx");
let cid = classify(&id, &roots).unwrap();
let cslug = classify(&slug, &roots).unwrap();
assert_ne!(cid.collision_bucket(), cslug.collision_bucket());
}
#[test]
fn dynamic_occurrence_position_and_spelling() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let p = PathBuf::from("/repo/app/shop/[id]/edit/page.tsx");
let c = classify(&p, &roots).unwrap();
let occ = c.dynamic_occurrences();
assert_eq!(occ.len(), 1);
assert_eq!(occ[0].position, "/shop");
assert_eq!(occ[0].spelling, "[id]");
assert_eq!(occ[0].slot_key, "");
}
#[test]
fn decorator_files_classified_but_not_leaves() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let p = PathBuf::from("/repo/app/shop/[id]/layout.tsx");
let c = classify(&p, &roots).unwrap();
assert!(!c.is_url_leaf());
assert_eq!(c.dynamic_occurrences()[0].spelling, "[id]");
}
#[test]
fn catch_all_kinds_parsed() {
let root = pkg("/repo");
let roots: Vec<&Path> = vec![root.as_path()];
let catch = PathBuf::from("/repo/app/docs/[...slug]/page.tsx");
let opt = PathBuf::from("/repo/app/docs/[[...slug]]/page.tsx");
assert_eq!(
classify(&catch, &roots).unwrap().dynamic_occurrences()[0].spelling,
"[...slug]"
);
assert_eq!(
classify(&opt, &roots).unwrap().dynamic_occurrences()[0].spelling,
"[[...slug]]"
);
}
}