use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::ModuleInfo;
use crate::discover::FileId;
use crate::graph::{ModuleGraph, ModuleNode};
use crate::results::UnusedLoadDataKey;
use crate::suppress::{IssueKind, SuppressionContext};
use super::{LineOffsetsMap, byte_offset_to_line_col};
const PAGE_LOAD_PRODUCER_NAMES: &[&str] =
&["+page.ts", "+page.server.ts", "+page.js", "+page.server.js"];
const SERVER_LOAD_PRODUCER_NAMES: &[&str] = &["+page.server.ts", "+page.server.js"];
const UNIVERSAL_LOAD_NAMES: &[&str] = &["+page.ts", "+page.js"];
const ROUTE_LOADER_DATA_OBJECT: &str = "$fallow.routeLoaderData";
const REACT_ROUTER_DEPS: &[&str] = &["react-router", "react-router-dom", "@react-router/dev"];
const REMIX_DEPS: &[&str] = &[
"@remix-run/react",
"@remix-run/node",
"@remix-run/cloudflare",
"@remix-run/deno",
];
pub struct LoadDataKeyResult {
pub findings: Vec<UnusedLoadDataKey>,
pub global_abstain: bool,
}
#[must_use]
pub fn find_unused_load_data_keys(
graph: &ModuleGraph,
modules: &[ModuleInfo],
declared_deps: &FxHashSet<String>,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
root: &Path,
) -> LoadDataKeyResult {
let has_sveltekit = declared_deps.contains("@sveltejs/kit");
let has_route_loader_deps = declared_deps
.iter()
.any(|dep| REACT_ROUTER_DEPS.contains(&dep.as_str()) || REMIX_DEPS.contains(&dep.as_str()));
if !has_sveltekit && !has_route_loader_deps {
return empty_result();
}
let global_abstain = has_sveltekit && modules.iter().any(|m| m.has_page_data_store_whole_use);
let module_indexes = build_module_indexes(graph, modules);
let global_used = collect_global_page_data_member_accesses(modules);
let mut findings = Vec::new();
if has_sveltekit && !global_abstain {
findings.extend(collect_unused_load_data_key_findings(
&LoadDataKeyScanInput {
graph,
module_indexes: &module_indexes,
global_used: &global_used,
root,
suppressions,
line_offsets_by_file,
},
));
}
if has_route_loader_deps {
findings.extend(collect_unused_route_loader_data_key_findings(
&LoadDataKeyScanInput {
graph,
module_indexes: &module_indexes,
global_used: &global_used,
root,
suppressions,
line_offsets_by_file,
},
));
}
LoadDataKeyResult {
findings,
global_abstain,
}
}
fn collect_unused_route_loader_data_key_findings(
input: &LoadDataKeyScanInput<'_>,
) -> Vec<UnusedLoadDataKey> {
let mut findings = Vec::new();
for node in &input.graph.modules {
let Some(candidate) =
route_loader_candidate_for_node(node, input.module_indexes, input.root)
else {
continue;
};
let ProducerCandidate {
producer,
file_id,
producer_path,
route_dir,
route_used,
} = candidate;
let finding_input = ProducerFindingInput {
producer,
file_id,
producer_path,
route_dir,
route_used: &route_used,
suppressions: input.suppressions,
line_offsets_by_file: input.line_offsets_by_file,
};
append_unused_keys_for_producer(&mut findings, &finding_input);
}
findings
}
fn route_loader_candidate_for_node<'a>(
node: &ModuleNode,
module_indexes: &ModuleIndexes<'a>,
root: &Path,
) -> Option<ProducerCandidate<'a>> {
let producer = module_indexes.module_by_id.get(&node.file_id).copied()?;
if producer.load_return_keys.is_empty() || producer.has_unharvestable_load {
return None;
}
if !is_conventional_route_loader_file(&node.path) {
return None;
}
let route_used = collect_route_loader_used_keys(producer)?;
let route_dir = node.path.parent();
let producer_path = *module_indexes.path_by_id.get(&node.file_id)?;
Some(ProducerCandidate {
producer,
file_id: node.file_id,
producer_path,
route_dir: route_dir.and_then(|dir| relativize_route_dir(dir, root)),
route_used,
})
}
fn collect_route_loader_used_keys(module: &ModuleInfo) -> Option<FxHashSet<&str>> {
if module.has_route_loader_data_whole_use
|| module
.whole_object_uses
.iter()
.any(|name| name == ROUTE_LOADER_DATA_OBJECT)
{
return None;
}
let mut used = FxHashSet::default();
for access in &module.member_accesses {
if access.object == ROUTE_LOADER_DATA_OBJECT || access.object == "loaderData" {
used.insert(access.member.as_str());
}
}
Some(used)
}
struct LoadDataKeyScanInput<'a> {
graph: &'a ModuleGraph,
module_indexes: &'a ModuleIndexes<'a>,
global_used: &'a FxHashSet<&'a str>,
root: &'a Path,
suppressions: &'a SuppressionContext<'a>,
line_offsets_by_file: &'a LineOffsetsMap<'a>,
}
fn collect_unused_load_data_key_findings(
input: &LoadDataKeyScanInput<'_>,
) -> Vec<UnusedLoadDataKey> {
let mut findings = Vec::new();
for node in &input.graph.modules {
let Some(candidate) =
producer_candidate_for_node(node, input.module_indexes, input.global_used, input.root)
else {
continue;
};
let ProducerCandidate {
producer,
file_id,
producer_path,
route_dir,
route_used,
} = candidate;
let finding_input = ProducerFindingInput {
producer,
file_id,
producer_path,
route_dir,
route_used: &route_used,
suppressions: input.suppressions,
line_offsets_by_file: input.line_offsets_by_file,
};
append_unused_keys_for_producer(&mut findings, &finding_input);
}
findings
}
struct ProducerCandidate<'a> {
producer: &'a ModuleInfo,
file_id: FileId,
producer_path: &'a Path,
route_dir: Option<String>,
route_used: FxHashSet<&'a str>,
}
fn producer_candidate_for_node<'a>(
node: &ModuleNode,
module_indexes: &ModuleIndexes<'a>,
global_used: &FxHashSet<&'a str>,
root: &Path,
) -> Option<ProducerCandidate<'a>> {
let producer = module_indexes.module_by_id.get(&node.file_id).copied()?;
if producer.load_return_keys.is_empty() || producer.has_unharvestable_load {
return None;
}
if !is_page_load_producer(&node.path) {
return None;
}
let route_dir = node.path.parent()?;
let route_used = collect_route_used_keys(
route_dir,
&node.path,
&module_indexes.module_by_path,
global_used,
)?;
let producer_path = *module_indexes.path_by_id.get(&node.file_id)?;
Some(ProducerCandidate {
producer,
file_id: node.file_id,
producer_path,
route_dir: relativize_route_dir(route_dir, root),
route_used,
})
}
fn empty_result() -> LoadDataKeyResult {
LoadDataKeyResult {
findings: Vec::new(),
global_abstain: false,
}
}
struct ModuleIndexes<'a> {
module_by_id: FxHashMap<FileId, &'a ModuleInfo>,
module_by_path: FxHashMap<&'a Path, &'a ModuleInfo>,
path_by_id: FxHashMap<FileId, &'a Path>,
}
fn build_module_indexes<'a>(
graph: &'a ModuleGraph,
modules: &'a [ModuleInfo],
) -> ModuleIndexes<'a> {
let module_by_id: FxHashMap<FileId, &ModuleInfo> = modules
.iter()
.map(|module| (module.file_id, module))
.collect();
ModuleIndexes {
module_by_path: graph
.modules
.iter()
.filter_map(|node| {
let module = module_by_id.get(&node.file_id).copied()?;
Some((node.path.as_path(), module))
})
.collect(),
module_by_id,
path_by_id: graph
.modules
.iter()
.map(|node| (node.file_id, node.path.as_path()))
.collect(),
}
}
fn collect_global_page_data_member_accesses(modules: &[ModuleInfo]) -> FxHashSet<&str> {
let mut global_used: FxHashSet<&str> = FxHashSet::default();
for module in modules {
for access in &module.member_accesses {
if access.object == "page.data" || access.object == "$page.data" {
global_used.insert(access.member.as_str());
}
}
}
global_used
}
fn collect_route_used_keys<'a>(
route_dir: &Path,
producer_path: &Path,
module_by_path: &FxHashMap<&Path, &'a ModuleInfo>,
global_used: &FxHashSet<&'a str>,
) -> Option<FxHashSet<&'a str>> {
let svelte_sibling = module_by_path
.get(route_dir.join("+page.svelte").as_path())
.copied();
if let Some(sibling) = svelte_sibling
&& sibling_passes_whole_data(sibling)
{
return None;
}
let mut route_used = global_used.clone();
if let Some(sibling) = svelte_sibling {
collect_data_member_accesses(sibling, &mut route_used);
}
if is_server_load_producer(producer_path) {
collect_universal_load_used_keys(route_dir, module_by_path, &mut route_used)?;
}
Some(route_used)
}
fn collect_universal_load_used_keys<'a>(
route_dir: &Path,
module_by_path: &FxHashMap<&Path, &'a ModuleInfo>,
route_used: &mut FxHashSet<&'a str>,
) -> Option<()> {
for universal_name in UNIVERSAL_LOAD_NAMES {
let Some(universal) = module_by_path
.get(route_dir.join(universal_name).as_path())
.copied()
else {
continue;
};
if sibling_passes_whole_data(universal) {
return None;
}
collect_data_member_accesses(universal, route_used);
}
Some(())
}
struct ProducerFindingInput<'a> {
producer: &'a ModuleInfo,
file_id: FileId,
producer_path: &'a Path,
route_dir: Option<String>,
route_used: &'a FxHashSet<&'a str>,
suppressions: &'a SuppressionContext<'a>,
line_offsets_by_file: &'a LineOffsetsMap<'a>,
}
fn append_unused_keys_for_producer(
findings: &mut Vec<UnusedLoadDataKey>,
input: &ProducerFindingInput<'_>,
) {
for key in &input.producer.load_return_keys {
if input.route_used.contains(key.name.as_str()) {
continue;
}
let (line, col) =
byte_offset_to_line_col(input.line_offsets_by_file, input.file_id, key.span_start);
if input
.suppressions
.is_suppressed(input.file_id, line, IssueKind::UnusedLoadDataKey)
|| input
.suppressions
.is_file_suppressed(input.file_id, IssueKind::UnusedLoadDataKey)
{
continue;
}
findings.push(UnusedLoadDataKey {
path: input.producer_path.to_path_buf(),
key_name: key.name.clone(),
line,
col,
route_dir: input.route_dir.clone(),
});
}
}
fn sibling_passes_whole_data(module: &ModuleInfo) -> bool {
module.has_load_data_whole_use
}
fn collect_data_member_accesses<'a>(module: &'a ModuleInfo, used: &mut FxHashSet<&'a str>) {
for access in &module.member_accesses {
if access.object == "data" {
used.insert(access.member.as_str());
}
}
}
fn is_page_load_producer(path: &Path) -> bool {
matches_basename(path, PAGE_LOAD_PRODUCER_NAMES)
}
fn is_server_load_producer(path: &Path) -> bool {
matches_basename(path, SERVER_LOAD_PRODUCER_NAMES)
}
fn is_conventional_route_loader_file(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if name.starts_with('+') {
return false;
}
if !matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("ts" | "tsx" | "js" | "jsx")
) {
return false;
}
if matches!(name, "root.ts" | "root.tsx" | "root.js" | "root.jsx")
&& path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|part| part.to_str())
.is_some_and(|part| matches!(part, "app" | "src"))
{
return true;
}
path_has_route_dir(path, "app") || path_has_route_dir(path, "src")
}
fn path_has_route_dir(path: &Path, app_dir: &str) -> bool {
let mut previous = None;
for part in path.components().filter_map(|c| c.as_os_str().to_str()) {
if previous == Some(app_dir) && part == "routes" {
return true;
}
previous = Some(part);
}
false
}
fn matches_basename(path: &Path, names: &[&str]) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| names.contains(&name))
}
fn relativize_route_dir(absolute_route_dir: &Path, root: &Path) -> Option<String> {
absolute_route_dir
.strip_prefix(root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
use fallow_types::extract::{LoadReturnKey, MemberAccess};
use rustc_hash::{FxHashMap, FxHashSet};
use crate::analyze::test_support::empty_module;
use crate::graph::ModuleGraph;
use crate::resolve::ResolvedModule;
use crate::suppress::SuppressionContext;
use super::{ROUTE_LOADER_DATA_OBJECT, find_unused_load_data_keys};
fn graph_for_paths(paths: &[PathBuf]) -> ModuleGraph {
let files: Vec<DiscoveredFile> = paths
.iter()
.enumerate()
.map(|(idx, path)| DiscoveredFile {
id: FileId(u32::try_from(idx).expect("test file count fits u32")),
path: path.clone(),
size_bytes: 0,
})
.collect();
let resolved: Vec<ResolvedModule> = files
.iter()
.map(|file| ResolvedModule {
file_id: file.id,
path: file.path.clone(),
..Default::default()
})
.collect();
let entry_points: Vec<EntryPoint> = Vec::new();
ModuleGraph::build(&resolved, &entry_points, &files)
}
#[test]
fn sveltekit_global_abstain_does_not_skip_route_loader_branch() {
let root = Path::new("/repo");
let global_path = root.join("src/lib/global.ts");
let route_path = root.join("app/routes/home.tsx");
let graph = graph_for_paths(&[global_path, route_path]);
let mut global = empty_module();
global.file_id = FileId(0);
global.has_page_data_store_whole_use = true;
let mut route = empty_module();
route.file_id = FileId(1);
route.load_return_keys = vec![
LoadReturnKey {
name: "used".to_string(),
span_start: 0,
span_end: 4,
},
LoadReturnKey {
name: "dead".to_string(),
span_start: 6,
span_end: 10,
},
];
route.member_accesses = vec![MemberAccess {
object: ROUTE_LOADER_DATA_OBJECT.to_string(),
member: "used".to_string(),
}];
let mut declared_deps = FxHashSet::default();
declared_deps.insert("@sveltejs/kit".to_string());
declared_deps.insert("react-router".to_string());
let line_offsets = FxHashMap::default();
let result = find_unused_load_data_keys(
&graph,
&[global, route],
&declared_deps,
&SuppressionContext::empty(),
&line_offsets,
root,
);
let keys: Vec<&str> = result
.findings
.iter()
.map(|finding| finding.key_name.as_str())
.collect();
assert!(result.global_abstain);
assert_eq!(keys, vec!["dead"]);
}
#[test]
fn released_route_loader_whole_use_still_abstains() {
let root = Path::new("/repo");
let route_path = root.join("app/routes/home.tsx");
let graph = graph_for_paths(std::slice::from_ref(&route_path));
let mut route = empty_module();
route.file_id = FileId(0);
route.load_return_keys = vec![LoadReturnKey {
name: "opaque".to_string(),
span_start: 0,
span_end: 6,
}];
route.whole_object_uses = vec![ROUTE_LOADER_DATA_OBJECT.to_string()].into();
route.release_resolution_payload();
let mut declared_deps = FxHashSet::default();
declared_deps.insert("react-router".to_string());
let result = find_unused_load_data_keys(
&graph,
&[route],
&declared_deps,
&SuppressionContext::empty(),
&FxHashMap::default(),
root,
);
assert!(
result.findings.is_empty(),
"an opaque route-loader use must abstain after resolution payload release"
);
}
#[test]
fn sparse_module_ids_do_not_reassign_loader_keys_to_missing_file() {
let root = Path::new("/repo");
let first_path = root.join("app/routes/first.tsx");
let missing_path = root.join("app/routes/missing.tsx");
let producer_path = root.join("app/routes/producer.tsx");
let graph = graph_for_paths(&[first_path, missing_path.clone(), producer_path.clone()]);
let mut first = empty_module();
first.file_id = FileId(0);
let mut producer = empty_module();
producer.file_id = FileId(2);
producer.load_return_keys = vec![LoadReturnKey {
name: "dead".to_string(),
span_start: 0,
span_end: 4,
}];
let mut declared_deps = FxHashSet::default();
declared_deps.insert("react-router".to_string());
let result = find_unused_load_data_keys(
&graph,
&[first, producer],
&declared_deps,
&SuppressionContext::empty(),
&FxHashMap::default(),
root,
);
assert_eq!(result.findings.len(), 1);
assert_eq!(result.findings[0].path, producer_path);
assert_ne!(result.findings[0].path, missing_path);
}
}