use rustc_hash::FxHashMap;
use fallow_types::extract::{ExportName, ModuleInfo};
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::results::InvalidClientExport;
use crate::suppress::{IssueKind, SuppressionContext};
use super::{LineOffsetsMap, byte_offset_to_line_col};
const USE_CLIENT_DIRECTIVE: &str = "use client";
const ILLEGAL_CLIENT_EXPORTS: &[&str] = &[
"metadata",
"generateMetadata",
"viewport",
"generateViewport",
"generateStaticParams",
"dynamic",
"dynamicParams",
"revalidate",
"fetchCache",
"runtime",
"preferredRegion",
"maxDuration",
"getServerSideProps",
"getStaticProps",
"getStaticPaths",
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
];
#[must_use]
pub fn find_invalid_client_exports(
graph: &ModuleGraph,
modules: &[ModuleInfo],
declared_deps: &rustc_hash::FxHashSet<String>,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<InvalidClientExport> {
if !declared_deps.contains("next") {
return Vec::new();
}
let path_by_id: FxHashMap<FileId, &std::path::Path> = graph
.modules
.iter()
.map(|module| (module.file_id, module.path.as_path()))
.collect();
let mut findings = Vec::new();
for module in modules {
if !module
.directives
.iter()
.any(|directive| directive == USE_CLIENT_DIRECTIVE)
{
continue;
}
let Some(path) = path_by_id.get(&module.file_id) else {
continue;
};
for export in &module.exports {
if export.is_type_only {
continue;
}
let ExportName::Named(name) = &export.name else {
continue;
};
if !ILLEGAL_CLIENT_EXPORTS.contains(&name.as_str()) {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, export.span.start);
if suppressions.is_suppressed(module.file_id, line, IssueKind::InvalidClientExport) {
continue;
}
findings.push(InvalidClientExport {
path: path.to_path_buf(),
export_name: name.clone(),
directive: USE_CLIENT_DIRECTIVE.to_string(),
line,
col,
});
}
}
findings
}
#[cfg(test)]
mod tests {
use rustc_hash::{FxHashMap, FxHashSet};
use crate::graph::ModuleGraph;
use crate::suppress::SuppressionContext;
use super::{ILLEGAL_CLIENT_EXPORTS, find_invalid_client_exports};
#[test]
fn next_gate_returns_empty_without_next_dependency() {
let graph = ModuleGraph::build(&[], &[], &[]);
let modules = Vec::new();
let declared: FxHashSet<String> = std::iter::once("react".to_string()).collect();
let suppressions = SuppressionContext::empty();
let offsets = FxHashMap::default();
let findings =
find_invalid_client_exports(&graph, &modules, &declared, &suppressions, &offsets);
assert!(
findings.is_empty(),
"no `next` dependency means no findings"
);
}
#[test]
fn illegal_set_covers_server_only_names_and_excludes_default() {
for name in [
"metadata",
"generateMetadata",
"viewport",
"generateStaticParams",
"dynamic",
"revalidate",
"runtime",
"getServerSideProps",
"GET",
"POST",
"OPTIONS",
] {
assert!(
ILLEGAL_CLIENT_EXPORTS.contains(&name),
"`{name}` should be in the illegal set"
);
}
assert!(!ILLEGAL_CLIENT_EXPORTS.contains(&"default"));
assert!(!ILLEGAL_CLIENT_EXPORTS.contains(&"useThing"));
}
#[test]
fn illegal_set_has_no_duplicates() {
let mut sorted = ILLEGAL_CLIENT_EXPORTS.to_vec();
sorted.sort_unstable();
let len = sorted.len();
sorted.dedup();
assert_eq!(len, sorted.len(), "ILLEGAL_CLIENT_EXPORTS has a duplicate");
}
}