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"];
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 {
if !declared_deps.contains("@sveltejs/kit") {
return empty_result();
}
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 module_indexes = build_module_indexes(graph, modules);
let global_used = collect_global_page_data_member_accesses(modules);
let findings = collect_unused_load_data_key_findings(
graph,
modules,
&module_indexes,
&global_used,
root,
suppressions,
line_offsets_by_file,
);
LoadDataKeyResult {
findings,
global_abstain: false,
}
}
fn collect_unused_load_data_key_findings(
graph: &ModuleGraph,
modules: &[ModuleInfo],
module_indexes: &ModuleIndexes<'_>,
global_used: &FxHashSet<&str>,
root: &Path,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<UnusedLoadDataKey> {
let mut findings = Vec::new();
for node in &graph.modules {
let Some(candidate) =
producer_candidate_for_node(node, modules, module_indexes, global_used, 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,
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,
modules: &'a [ModuleInfo],
module_indexes: &ModuleIndexes<'a>,
global_used: &FxHashSet<&'a str>,
root: &Path,
) -> Option<ProducerCandidate<'a>> {
let producer = modules.get(node.file_id.0 as usize)?;
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_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> {
ModuleIndexes {
module_by_path: graph
.modules
.iter()
.filter_map(|node| {
let module = modules.get(node.file_id.0 as usize)?;
Some((node.path.as_path(), module))
})
.collect(),
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 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('\\', "/"))
}