use std::path::{Path, PathBuf};
use fallow_types::discover::FileId;
use rustc_hash::FxHashSet;
use super::ModuleGraph;
impl ModuleGraph {
#[must_use]
pub fn public_export_keys(
&self,
public_api_entry_points: &FxHashSet<FileId>,
root: &Path,
) -> FxHashSet<String> {
let star_targets = self.public_star_re_export_targets(public_api_entry_points);
let mut keys: FxHashSet<String> = FxHashSet::default();
for module in &self.modules {
let module_is_public = public_api_entry_points.contains(&module.file_id)
|| star_targets.contains(&module.file_id);
if !module_is_public {
continue;
}
let rel = relativize(&module.path, root);
for export in &module.exports {
if export.is_type_only {
continue;
}
keys.insert(format!("{rel}::{}", export.name));
}
}
keys
}
fn public_star_re_export_targets(
&self,
public_api_entry_points: &FxHashSet<FileId>,
) -> FxHashSet<FileId> {
let mut targets: FxHashSet<FileId> = public_api_entry_points
.iter()
.filter_map(|id| self.modules.get(id.0 as usize))
.flat_map(|module| {
module
.re_exports
.iter()
.filter(|re| re.exported_name == "*")
.map(|re| re.source_file)
})
.collect();
let mut stack: Vec<FileId> = targets.iter().copied().collect();
while let Some(id) = stack.pop() {
let Some(module) = self.modules.get(id.0 as usize) else {
continue;
};
for re in module
.re_exports
.iter()
.filter(|re| re.exported_name == "*")
{
if targets.insert(re.source_file) {
stack.push(re.source_file);
}
}
}
targets
}
}
fn relativize(path: &Path, root: &Path) -> String {
let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
rel.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
use fallow_types::extract::{
ExportInfo, ExportName, ImportInfo, ImportedName, ReExportInfo, VisibilityTag,
};
use std::path::PathBuf;
fn file(id: u32, path: &str) -> DiscoveredFile {
DiscoveredFile {
id: FileId(id),
path: PathBuf::from(path),
size_bytes: 10,
}
}
fn named_export(name: &str) -> ExportInfo {
ExportInfo {
name: ExportName::Named(name.to_string()),
local_name: Some(name.to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: oxc_span::Span::new(0, 20),
members: vec![],
is_side_effect_used: false,
super_class: None,
}
}
fn re_export(imported: &str, exported: &str, target: FileId) -> ResolvedReExport {
ResolvedReExport {
info: ReExportInfo {
source: "./impl".to_string(),
imported_name: imported.to_string(),
exported_name: exported.to_string(),
is_type_only: false,
span: oxc_span::Span::new(0, 10),
},
target: ResolveResult::InternalModule(target),
}
}
fn named_import(name: &str, target: FileId) -> ResolvedImport {
ResolvedImport {
info: ImportInfo {
source: "./x".to_string(),
imported_name: ImportedName::Named(name.to_string()),
local_name: name.to_string(),
is_type_only: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(target),
}
}
fn build_graph() -> (ModuleGraph, FxHashSet<FileId>) {
let files = vec![
file(0, "/p/index.js"),
file(1, "/p/src/impl.ts"),
file(2, "/p/src/internal.ts"),
file(3, "/p/src/consumer.ts"),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/index.js"),
source: EntryPointSource::PackageJsonExports,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/index.js"),
re_exports: vec![re_export("pub", "pub", FileId(1))],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/impl.ts"),
exports: vec![named_export("pub"), named_export("priv")],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/p/src/internal.ts"),
re_exports: vec![re_export("priv", "priv", FileId(1))],
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: PathBuf::from("/p/src/consumer.ts"),
resolved_imports: vec![
named_import("pub", FileId(0)),
named_import("priv", FileId(2)),
],
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
(graph, public_entries)
}
#[test]
fn export_reexported_through_exports_path_is_public() {
let (graph, public_entries) = build_graph();
let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
assert!(
keys.contains("index.js::pub"),
"exports-reachable symbol must be public: {keys:?}"
);
}
#[test]
fn export_reexported_only_through_internal_barrel_is_not_public() {
let (graph, public_entries) = build_graph();
let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
assert!(
!keys.iter().any(|k| k.ends_with("::priv")),
"internal-barrel-only symbol must NOT be public: {keys:?}"
);
}
fn build_aisha_graph(
impl_exports: &[&str],
exports_reexported: &[&str],
internal_reexported: &[&str],
) -> (ModuleGraph, FxHashSet<FileId>) {
let files = vec![
file(0, "/p/index.js"),
file(1, "/p/src/impl.ts"),
file(2, "/p/src/internal.ts"),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/index.js"),
source: EntryPointSource::PackageJsonExports,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/index.js"),
re_exports: exports_reexported
.iter()
.map(|n| re_export(n, n, FileId(1)))
.collect(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/impl.ts"),
exports: impl_exports.iter().map(|n| named_export(n)).collect(),
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/p/src/internal.ts"),
re_exports: internal_reexported
.iter()
.map(|n| re_export(n, n, FileId(1)))
.collect(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
(graph, public_entries)
}
#[test]
fn done_condition_internal_zero_exports_one() {
let root = Path::new("/p");
let (base_graph, base_entries) = build_aisha_graph(&["pub"], &["pub"], &[]);
let base = base_graph.public_export_keys(&base_entries, root);
let (head_a_graph, head_a_entries) =
build_aisha_graph(&["pub", "internalOnly"], &["pub"], &["internalOnly"]);
let head_a = head_a_graph.public_export_keys(&head_a_entries, root);
let internal_delta: Vec<_> = head_a.difference(&base).collect();
assert!(
internal_delta.is_empty(),
"internal-barrel symbol must yield ZERO public-API delta: {internal_delta:?}"
);
let (head_b_graph, head_b_entries) =
build_aisha_graph(&["pub", "widget"], &["pub", "widget"], &[]);
let head_b = head_b_graph.public_export_keys(&head_b_entries, root);
let exports_delta: Vec<_> = head_b.difference(&base).collect();
assert_eq!(
exports_delta.len(),
1,
"exports-reachable symbol must yield EXACTLY ONE public-API delta: {exports_delta:?}"
);
assert_eq!(exports_delta[0], "index.js::widget");
}
#[test]
fn type_only_exports_are_skipped() {
let files = vec![file(0, "/p/index.ts")];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/index.ts"),
source: EntryPointSource::PackageJsonExports,
}];
let mut type_export = named_export("T");
type_export.is_type_only = true;
let resolved = vec![ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/index.ts"),
exports: vec![type_export, named_export("v")],
..Default::default()
}];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
assert!(keys.contains("index.ts::v"));
assert!(!keys.contains("index.ts::T"), "type-only export skipped");
}
}