use std::{
fs,
path::{Path, PathBuf},
};
use oxc_allocator::Allocator;
use oxc_ast::ast::{
CallExpression, ExportDefaultDeclarationKind, Expression, ObjectExpression, Program, Statement,
};
use oxc_parser::Parser;
use oxc_span::SourceType;
use super::{Plugin, PluginResult, config_parser};
const ENABLERS: &[&str] = &["@react-router/dev"];
const CONFIG_PATTERNS: &[&str] = &[
"react-router.config.{ts,js,mjs,cjs}",
"app/routes.{ts,js,mts,mjs}",
"src/routes.{ts,js,mts,mjs}",
];
const ENTRY_PATTERNS: &[&str] = &[
"app/routes/**/*.{ts,tsx,js,jsx}",
"app/root.{ts,tsx,js,jsx}",
"app/entry.client.{ts,tsx,js,jsx}",
"app/entry.server.{ts,tsx,js,jsx}",
];
const ALWAYS_USED: &[&str] = &[
"react-router.config.{ts,js,mjs,cjs}",
"app/routes.{ts,js,mts,mjs}",
"src/routes.{ts,js,mts,mjs}",
];
const TOOLING_DEPENDENCIES: &[&str] = &[
"@react-router/dev",
"@react-router/serve",
"@react-router/node",
];
macro_rules! route_module_exports {
($($export:literal),+ $(,)?) => {
const ROUTE_EXPORTS: &[&str] = &[$($export),+];
const ROOT_EXPORTS: &[&str] = &[$($export,)+ "Layout"];
};
}
route_module_exports!(
"default",
"loader",
"clientLoader",
"action",
"clientAction",
"meta",
"links",
"headers",
"handle",
"ErrorBoundary",
"HydrateFallback",
"shouldRevalidate",
"middleware",
"clientMiddleware",
);
const ROUTE_CONFIG_EXPORTS: &[&str] = &["default"];
define_plugin! {
struct ReactRouterPlugin => "react-router",
enablers: ENABLERS,
entry_patterns: ENTRY_PATTERNS,
config_patterns: CONFIG_PATTERNS,
always_used: ALWAYS_USED,
tooling_dependencies: TOOLING_DEPENDENCIES,
used_exports: [
("app/routes*.{ts,tsx,js,jsx}", ROUTE_EXPORTS),
("app/root.{ts,tsx,js,jsx}", ROOT_EXPORTS),
("app/routes.{ts,js,mts,mjs}", ROUTE_CONFIG_EXPORTS),
],
resolve_config(config_path, source, root) {
let mut result = PluginResult::default();
add_referenced_dependencies(&mut result, source, config_path);
if is_react_router_project_config(config_path) {
let app_dir = extract_app_directory(source, config_path, root);
add_app_dir_patterns(&mut result, &app_dir);
if !matches!(app_dir.as_str(), "app" | "src") {
collect_route_config_from_disk(&mut result, root, &app_dir);
}
return result;
}
let Some(app_dir) = route_config_app_dir(config_path, root) else {
return result;
};
add_app_dir_patterns(&mut result, &app_dir);
collect_route_config_from_source(&mut result, source, config_path, root);
result
},
}
fn add_referenced_dependencies(result: &mut PluginResult, source: &str, config_path: &Path) {
for import in config_parser::extract_imports(source, config_path) {
result
.referenced_dependencies
.push(crate::resolve::extract_package_name(&import));
}
}
fn is_react_router_project_config(config_path: &Path) -> bool {
config_path.file_name().is_some_and(|name| {
let file_name = name.to_string_lossy();
file_name.starts_with("react-router.config.")
})
}
fn extract_app_directory(source: &str, config_path: &Path, root: &Path) -> String {
config_parser::extract_config_string(source, config_path, &["appDirectory"])
.as_deref()
.and_then(|raw| config_parser::normalize_config_path(raw, config_path, root))
.unwrap_or_else(|| "app".to_string())
}
fn route_config_app_dir(config_path: &Path, root: &Path) -> Option<String> {
let file_name = config_path.file_name()?.to_string_lossy();
if !file_name.starts_with("routes.") {
return None;
}
let parent = config_path.parent().unwrap_or(root);
let relative = parent.strip_prefix(root).ok()?;
let normalized = relative.to_string_lossy().replace('\\', "/");
(!normalized.is_empty()).then_some(normalized)
}
fn add_app_dir_patterns(result: &mut PluginResult, app_dir: &str) {
let route_dir_pattern = route_dir_pattern(app_dir);
let route_config_pattern = route_config_pattern(app_dir);
let root_pattern = format!("{app_dir}/root.{{ts,tsx,js,jsx}}");
result.push_entry_pattern(route_dir_pattern.clone());
result.push_entry_pattern(root_pattern.clone());
result.push_entry_pattern(format!("{app_dir}/entry.client.{{ts,tsx,js,jsx}}"));
result.push_entry_pattern(format!("{app_dir}/entry.server.{{ts,tsx,js,jsx}}"));
result.push_entry_pattern(route_config_pattern.clone());
result.push_used_export_rule(route_dir_pattern, ROUTE_EXPORTS.iter().copied());
result.push_used_export_rule(root_pattern, ROOT_EXPORTS.iter().copied());
result.push_used_export_rule(route_config_pattern, ROUTE_CONFIG_EXPORTS.iter().copied());
}
fn route_dir_pattern(app_dir: &str) -> String {
format!("{app_dir}/routes/**/*.{{ts,tsx,js,jsx}}")
}
fn route_config_pattern(app_dir: &str) -> String {
format!("{app_dir}/routes.{{ts,js,mts,mjs}}")
}
fn collect_route_config_from_disk(result: &mut PluginResult, root: &Path, app_dir: &str) {
for candidate in route_config_candidates(root, app_dir) {
if !candidate.is_file() {
continue;
}
let Ok(source) = fs::read_to_string(&candidate) else {
continue;
};
add_referenced_dependencies(result, &source, &candidate);
collect_route_config_from_source(result, &source, &candidate, root);
break;
}
}
fn route_config_candidates(root: &Path, app_dir: &str) -> [PathBuf; 4] {
[
root.join(app_dir).join("routes.ts"),
root.join(app_dir).join("routes.js"),
root.join(app_dir).join("routes.mts"),
root.join(app_dir).join("routes.mjs"),
]
}
fn collect_route_config_from_source(
result: &mut PluginResult,
source: &str,
route_config_path: &Path,
root: &Path,
) {
let source_type = SourceType::from_path(route_config_path).unwrap_or_default();
let alloc = Allocator::default();
let parsed = Parser::new(&alloc, source, source_type).parse();
let Some(expr) = find_default_export_expression(&parsed.program) else {
return;
};
let base_dir = route_config_path.parent().unwrap_or(root);
collect_route_entries(expr, &parsed.program, base_dir, root, result);
}
fn find_default_export_expression<'a>(program: &'a Program<'a>) -> Option<&'a Expression<'a>> {
for stmt in &program.body {
let Statement::ExportDefaultDeclaration(decl) = stmt else {
continue;
};
let expr = match &decl.declaration {
ExportDefaultDeclarationKind::FunctionDeclaration(_)
| ExportDefaultDeclarationKind::ClassDeclaration(_) => return None,
_ => decl.declaration.as_expression()?,
};
let expr = strip_wrappers(expr);
if let Some(name) = unwrap_identifier_name(expr) {
return find_variable_init_expression(program, name);
}
return Some(expr);
}
None
}
fn find_variable_init_expression<'a>(
program: &'a Program<'a>,
name: &str,
) -> Option<&'a Expression<'a>> {
for stmt in &program.body {
let Statement::VariableDeclaration(decl) = stmt else {
continue;
};
for declarator in &decl.declarations {
let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = &declarator.id else {
continue;
};
if id.name == name {
return declarator.init.as_ref().map(strip_wrappers);
}
}
}
None
}
fn strip_wrappers<'a>(expr: &'a Expression<'a>) -> &'a Expression<'a> {
match expr {
Expression::ParenthesizedExpression(paren) => strip_wrappers(&paren.expression),
Expression::TSSatisfiesExpression(ts_sat) => strip_wrappers(&ts_sat.expression),
Expression::TSAsExpression(ts_as) => strip_wrappers(&ts_as.expression),
_ => expr,
}
}
fn unwrap_identifier_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
match strip_wrappers(expr) {
Expression::Identifier(id) => Some(id.name.as_str()),
_ => None,
}
}
fn collect_route_entries(
expr: &Expression<'_>,
program: &Program<'_>,
base_dir: &Path,
root: &Path,
result: &mut PluginResult,
) {
match strip_wrappers(expr) {
Expression::ArrayExpression(array) => {
for element in &array.elements {
let Some(element_expr) = element.as_expression() else {
continue;
};
collect_route_entries(element_expr, program, base_dir, root, result);
}
}
Expression::ObjectExpression(obj) => {
collect_route_object(obj, program, base_dir, root, result);
}
Expression::CallExpression(call) => {
collect_route_call(call, program, base_dir, root, result);
}
Expression::Identifier(id) => {
if let Some(init) = find_variable_init_expression(program, id.name.as_str()) {
collect_route_entries(init, program, base_dir, root, result);
}
}
_ => {}
}
}
fn collect_route_object(
obj: &ObjectExpression<'_>,
program: &Program<'_>,
base_dir: &Path,
root: &Path,
result: &mut PluginResult,
) {
if let Some(file) = config_parser::property_string(obj, "file") {
add_route_module_file(result, &file, base_dir, root);
}
if let Some(children) = config_parser::property_expr(obj, "children") {
collect_route_entries(children, program, base_dir, root, result);
}
}
fn collect_route_call(
call: &CallExpression<'_>,
program: &Program<'_>,
base_dir: &Path,
root: &Path,
result: &mut PluginResult,
) {
let Some(name) = unwrap_identifier_name(&call.callee) else {
return;
};
match name {
"route" => {
if let Some(file) = nth_argument_string(call, 1) {
add_route_module_file(result, &file, base_dir, root);
}
if let Some(children) = nth_argument_expression(call, 2) {
collect_route_entries(children, program, base_dir, root, result);
}
}
"index" => {
if let Some(file) = nth_argument_string(call, 0) {
add_route_module_file(result, &file, base_dir, root);
}
}
"layout" => {
if let Some(file) = nth_argument_string(call, 0) {
add_route_module_file(result, &file, base_dir, root);
}
if let Some(children) = nth_argument_expression(call, 1) {
collect_route_entries(children, program, base_dir, root, result);
}
}
"prefix" => {
if let Some(children) = nth_argument_expression(call, 1) {
collect_route_entries(children, program, base_dir, root, result);
}
}
"relative" => {
let next_base = nth_argument_string(call, 0)
.and_then(|raw| normalize_route_base_dir(&raw, base_dir, root))
.unwrap_or_else(|| base_dir.to_path_buf());
if let Some(children) = nth_argument_expression(call, 1) {
collect_route_entries(children, program, &next_base, root, result);
}
}
"flatRoutes" => {
if let Some(route_dir) = extract_flat_routes_root_dir(call, base_dir, root) {
add_route_dir_override(result, &route_dir);
}
}
_ => {
for argument in &call.arguments {
let Some(argument_expr) = argument.as_expression() else {
continue;
};
collect_route_entries(argument_expr, program, base_dir, root, result);
}
}
}
}
fn nth_argument_expression<'a>(
call: &'a CallExpression<'a>,
index: usize,
) -> Option<&'a Expression<'a>> {
call.arguments
.get(index)?
.as_expression()
.map(strip_wrappers)
}
fn nth_argument_string(call: &CallExpression<'_>, index: usize) -> Option<String> {
expression_string(nth_argument_expression(call, index)?)
}
fn expression_string(expr: &Expression<'_>) -> Option<String> {
match strip_wrappers(expr) {
Expression::StringLiteral(lit) => Some(lit.value.to_string()),
Expression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => {
tpl.quasis.first().map(|quasi| quasi.value.raw.to_string())
}
_ => None,
}
}
fn extract_flat_routes_root_dir(
call: &CallExpression<'_>,
base_dir: &Path,
root: &Path,
) -> Option<String> {
let options_expr = nth_argument_expression(call, 0)?;
let options = config_parser::object_expression(options_expr)?;
let raw = config_parser::property_string(options, "rootDirectory")?;
normalize_route_path(&raw, base_dir, root)
}
fn normalize_route_path(raw: &str, base_dir: &Path, root: &Path) -> Option<String> {
let synthetic_path = base_dir.join("__fallow__.ts");
config_parser::normalize_config_path(raw, &synthetic_path, root)
}
fn normalize_route_base_dir(raw: &str, base_dir: &Path, root: &Path) -> Option<PathBuf> {
normalize_route_path(raw, base_dir, root).map(|relative| root.join(relative))
}
fn add_route_module_file(result: &mut PluginResult, raw_path: &str, base_dir: &Path, root: &Path) {
let Some(path) = normalize_route_path(raw_path, base_dir, root) else {
return;
};
result.push_entry_pattern(path.clone());
result.push_used_export_rule(path, ROUTE_EXPORTS.iter().copied());
}
fn add_route_dir_override(result: &mut PluginResult, route_dir: &str) {
let pattern = format!("{route_dir}/**/*.{{ts,tsx,js,jsx}}");
result.push_entry_pattern(pattern.clone());
result.push_used_export_rule(pattern, ROUTE_EXPORTS.iter().copied());
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn used_exports_cover_root_and_route_config() {
let plugin = ReactRouterPlugin;
let exports = plugin.used_exports();
assert!(exports.iter().any(|(pattern, names)| {
pattern == &"app/root.{ts,tsx,js,jsx}" && names.contains(&"Layout")
}));
assert!(exports.iter().any(|(pattern, names)| {
pattern == &"app/routes/**/*.{ts,tsx,js,jsx}"
&& names.contains(&"clientMiddleware")
&& names.contains(&"middleware")
}));
assert!(exports.iter().any(|(pattern, names)| {
pattern == &"app/routes.{ts,js,mts,mjs}" && names == &["default"]
}));
}
fn has_entry_pattern(result: &PluginResult, pattern: &str) -> bool {
result
.entry_patterns
.iter()
.any(|entry_pattern| entry_pattern.pattern == pattern)
}
#[test]
fn resolve_config_honors_custom_app_directory() {
let plugin = ReactRouterPlugin;
let source = r#"export default { appDirectory: "src" };"#;
let result = plugin.resolve_config(
Path::new("/project/react-router.config.ts"),
source,
Path::new("/project"),
);
assert!(has_entry_pattern(&result, "src/root.{ts,tsx,js,jsx}"));
assert!(has_entry_pattern(&result, "src/routes.{ts,js,mts,mjs}"));
assert!(result.used_exports.iter().any(|rule| {
rule.path.pattern == "src/routes/**/*.{ts,tsx,js,jsx}"
&& rule.exports.iter().any(|export| export == "loader")
}));
}
#[test]
fn resolve_config_discovers_route_modules_and_flat_routes() {
let plugin = ReactRouterPlugin;
let source = r#"
import { flatRoutes } from "@react-router/fs-routes";
import { index, layout, route } from "@react-router/dev/routes";
export default [
index("./marketing/home.tsx"),
layout("./account/layout.tsx", [route("login", "./account/login.tsx")]),
flatRoutes({ rootDirectory: "file-routes" }),
];
"#;
let result = plugin.resolve_config(
Path::new("/project/app/routes.ts"),
source,
Path::new("/project"),
);
for pattern in [
"app/marketing/home.tsx",
"app/account/layout.tsx",
"app/account/login.tsx",
"app/file-routes/**/*.{ts,tsx,js,jsx}",
] {
assert!(
has_entry_pattern(&result, pattern),
"missing {pattern}: {:?}",
result.entry_patterns
);
}
assert!(result.used_exports.iter().any(|rule| {
rule.path.pattern == "app/account/login.tsx"
&& rule.exports.iter().any(|export| export == "clientAction")
}));
}
#[test]
fn resolve_config_reads_custom_route_file_from_disk() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join("web")).unwrap();
fs::write(
temp.path().join("web/routes.ts"),
r#"
import { index } from "@react-router/dev/routes";
export default [index("./marketing/home.tsx")];
"#,
)
.unwrap();
let plugin = ReactRouterPlugin;
let result = plugin.resolve_config(
temp.path().join("react-router.config.ts").as_path(),
r#"export default { appDirectory: "web" };"#,
temp.path(),
);
assert!(has_entry_pattern(&result, "web/marketing/home.tsx"));
}
}