use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::ModuleInfo;
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::results::{
AnalysisResults, UnusedExport, UnusedServerAction, UnusedServerActionFinding,
};
use crate::suppress::{IssueKind, SuppressionContext};
struct ServerActionIndexes<'a> {
use_server_ids: FxHashSet<FileId>,
inline_actions_by_id: FxHashMap<FileId, &'a [String]>,
file_id_by_path: FxHashMap<&'a Path, FileId>,
}
enum Reclassification {
KeepUnusedExport,
DropSuppressed,
MoveToServerAction(UnusedServerAction),
}
fn server_action_indexes<'a>(
graph: &'a ModuleGraph,
modules: &'a [ModuleInfo],
) -> Option<ServerActionIndexes<'a>> {
let use_server_ids: FxHashSet<FileId> = modules
.iter()
.filter(|m| m.directives.iter().any(|d| d == "use server"))
.map(|m| m.file_id)
.collect();
let inline_actions_by_id: FxHashMap<FileId, &[String]> = modules
.iter()
.filter(|m| !m.inline_server_action_exports.is_empty())
.map(|m| (m.file_id, m.inline_server_action_exports.as_slice()))
.collect();
if use_server_ids.is_empty() && inline_actions_by_id.is_empty() {
return None;
}
let file_id_by_path: FxHashMap<&Path, FileId> = graph
.modules
.iter()
.map(|node| (node.path.as_path(), node.file_id))
.collect();
Some(ServerActionIndexes {
use_server_ids,
inline_actions_by_id,
file_id_by_path,
})
}
fn reclassify_unused_export(
export: &UnusedExport,
indexes: &ServerActionIndexes<'_>,
suppressions: &SuppressionContext<'_>,
) -> Reclassification {
if export.is_type_only || export.is_re_export {
return Reclassification::KeepUnusedExport;
}
let Some(&file_id) = indexes.file_id_by_path.get(export.path.as_path()) else {
return Reclassification::KeepUnusedExport;
};
let is_whole_file_action = indexes.use_server_ids.contains(&file_id);
let is_inline_action = indexes
.inline_actions_by_id
.get(&file_id)
.is_some_and(|names| names.contains(&export.export_name));
if !is_whole_file_action && !is_inline_action {
return Reclassification::KeepUnusedExport;
}
if suppressions.is_suppressed(file_id, export.line, IssueKind::UnusedServerAction)
|| suppressions.is_file_suppressed(file_id, IssueKind::UnusedServerAction)
{
return Reclassification::DropSuppressed;
}
Reclassification::MoveToServerAction(UnusedServerAction {
path: export.path.clone(),
action_name: export.export_name.clone(),
line: export.line,
col: export.col,
})
}
pub fn reclassify_unused_server_actions(
graph: &ModuleGraph,
modules: &[ModuleInfo],
declared_deps: &FxHashSet<String>,
suppressions: &SuppressionContext<'_>,
results: &mut AnalysisResults,
) {
if !declared_deps.contains("next") {
return;
}
let Some(indexes) = server_action_indexes(graph, modules) else {
return;
};
let mut reclassified: Vec<UnusedServerAction> = Vec::new();
results.unused_exports.retain(|finding| {
match reclassify_unused_export(&finding.export, &indexes, suppressions) {
Reclassification::KeepUnusedExport => true,
Reclassification::DropSuppressed => false,
Reclassification::MoveToServerAction(action) => {
reclassified.push(action);
false
}
}
});
results.unused_server_actions = reclassified
.into_iter()
.map(UnusedServerActionFinding::with_actions)
.collect();
}