use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::ModuleInfo;
use crate::graph::ModuleGraph;
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"];
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 empty = LoadDataKeyResult {
findings: Vec::new(),
global_abstain: false,
};
if !declared_deps.contains("@sveltejs/kit") {
return empty;
}
let module_by_path: FxHashMap<&Path, &ModuleInfo> = graph
.modules
.iter()
.filter_map(|node| {
let module = modules.get(node.file_id.0 as usize)?;
Some((node.path.as_path(), module))
})
.collect();
let path_by_id: FxHashMap<_, &Path> = graph
.modules
.iter()
.map(|node| (node.file_id, node.path.as_path()))
.collect();
let global_abstain = modules.iter().any(|m| m.has_page_data_store_whole_use);
if global_abstain {
return LoadDataKeyResult {
findings: Vec::new(),
global_abstain: true,
};
}
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());
}
}
}
let mut findings = Vec::new();
for node in &graph.modules {
let Some(producer) = modules.get(node.file_id.0 as usize) else {
continue;
};
if producer.load_return_keys.is_empty() || producer.has_unharvestable_load {
continue;
}
if !is_page_load_producer(&node.path) {
continue;
}
let Some(route_dir) = node.path.parent() else {
continue;
};
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)
{
continue;
}
let mut route_used: FxHashSet<&str> = global_used.clone();
if let Some(sibling) = svelte_sibling {
collect_data_member_accesses(sibling, &mut route_used);
}
let mut server_chain_abstain = false;
if is_server_load_producer(&node.path) {
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) {
server_chain_abstain = true;
break;
}
collect_data_member_accesses(universal, &mut route_used);
}
}
if server_chain_abstain {
continue;
}
let Some(&producer_path) = path_by_id.get(&node.file_id) else {
continue;
};
let route_dir_rel = relativize_route_dir(route_dir, root);
for key in &producer.load_return_keys {
if route_used.contains(key.name.as_str()) {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, node.file_id, key.span_start);
if suppressions.is_suppressed(node.file_id, line, IssueKind::UnusedLoadDataKey)
|| suppressions.is_file_suppressed(node.file_id, IssueKind::UnusedLoadDataKey)
{
continue;
}
findings.push(UnusedLoadDataKey {
path: producer_path.to_path_buf(),
key_name: key.name.clone(),
line,
col,
route_dir: route_dir_rel.clone(),
});
}
}
LoadDataKeyResult {
findings,
global_abstain: false,
}
}
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 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('\\', "/"))
}