mod ambiguity;
mod build;
mod cycles;
mod effective_exports;
mod effective_re_exports;
mod fan_io;
mod impact_closure;
mod namespace_aliases;
mod namespace_indexes;
mod namespace_re_exports;
mod narrowing;
mod partition_order;
mod public_exports;
mod re_exports;
mod reachability;
pub mod types;
use std::path::Path;
use fixedbitset::FixedBitSet;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::resolve::{ResolvedModule, ResolvedReplacedModuleTarget};
use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
use fallow_types::extract::{ImportedName, ModuleLoadMechanism};
use types::{ReferencePathInterner, ReferencePathNode, ReferenceRouteNodeId, ReferenceRoutes};
pub use ambiguity::{AmbiguityParticipants, AmbiguousStarExport};
pub use effective_exports::{EffectiveExportBinding, EffectiveExportResolution, ExportNamespace};
pub use effective_re_exports::EffectiveReExportRoute;
pub use fan_io::{FocusFileFacts, FocusFileFactsPaths};
pub use impact_closure::{
CoordinationGap, CoordinationGapPaths, ImpactClosure, ImpactClosurePaths,
};
pub use partition_order::{PartitionOrder, PartitionOrderPaths, ReviewUnit, ReviewUnitPaths};
pub use public_exports::PublicExportOrigin;
pub use re_exports::GraphReExportCycle;
pub use types::{
ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, ReferencePathId, SymbolReference,
};
#[derive(Debug, Clone, Copy)]
pub struct EffectiveExportOrigin<'graph> {
file_id: FileId,
export: &'graph ExportSymbol,
}
#[derive(Debug, Clone, Copy)]
pub struct EffectiveExportSurface<'graph> {
binding: EffectiveExportBinding,
namespace: ExportNamespace,
export: Option<&'graph ExportSymbol>,
origin: Option<EffectiveExportOrigin<'graph>>,
local_export: bool,
}
impl<'graph> EffectiveExportSurface<'graph> {
#[must_use]
pub const fn binding(self) -> EffectiveExportBinding {
self.binding
}
#[must_use]
pub const fn namespace(self) -> ExportNamespace {
self.namespace
}
#[must_use]
pub const fn export(self) -> Option<&'graph ExportSymbol> {
self.export
}
#[must_use]
pub const fn origin(self) -> Option<EffectiveExportOrigin<'graph>> {
self.origin
}
#[must_use]
pub const fn has_local_export(self) -> bool {
self.local_export
}
}
impl<'graph> EffectiveExportOrigin<'graph> {
#[must_use]
pub const fn file_id(self) -> FileId {
self.file_id
}
#[must_use]
pub const fn export(self) -> &'graph ExportSymbol {
self.export
}
}
fn is_declaration_file_path(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| {
name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
})
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ModuleGraph {
pub modules: Vec<ModuleNode>,
edges: Vec<Edge>,
pub package_usage: FxHashMap<String, Vec<FileId>>,
pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
pub entry_points: FxHashSet<FileId>,
pub runtime_entry_points: FxHashSet<FileId>,
pub test_entry_points: FxHashSet<FileId>,
test_reachability_index: TestReachabilityIndex,
reference_paths: Vec<ReferencePathNode>,
reference_routes: ReferenceRoutes,
pub reverse_deps: Vec<Vec<FileId>>,
#[serde(skip, default)]
namespace_imported: FixedBitSet,
pub re_export_cycles: Vec<GraphReExportCycle>,
effective_exports: effective_exports::EffectiveExportIndex,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Edge {
source: FileId,
target: FileId,
symbols: Vec<ImportedSymbol>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ImportedSymbol {
pub imported_name: ImportedName,
pub local_name: String,
#[serde(with = "crate::cache::span_serde")]
pub import_span: oxc_span::Span,
pub is_type_only: bool,
pub is_type_only_star: bool,
mechanism: ModuleLoadMechanism,
}
impl ImportedSymbol {
#[must_use]
pub(crate) fn is_ambient_star(&self) -> bool {
self.is_type_only
&& self.local_name.is_empty()
&& matches!(
self.imported_name,
ImportedName::Namespace | ImportedName::Default
)
}
#[must_use]
pub(crate) fn is_value_bearing_ambient_star(&self) -> bool {
self.is_ambient_star() && !self.is_type_only_star
}
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct TestReachabilityIndex {
profile_count: usize,
words_per_file: usize,
reachable_profiles: Vec<u64>,
masked_profiles: Vec<MaskedTestProfiles>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct MaskedTestProfiles {
target: FileId,
profiles: Vec<u64>,
}
impl TestReachabilityIndex {
fn new(file_capacity: usize, profile_count: usize) -> Self {
let words_per_file = profile_count.div_ceil(u64::BITS as usize);
let storage_len = file_capacity.saturating_mul(words_per_file);
Self {
profile_count,
words_per_file,
reachable_profiles: vec![0; storage_len],
masked_profiles: Vec::new(),
}
}
fn set_sparse_masks(&mut self, masks: FxHashMap<FileId, Vec<u64>>) {
let mut rows: Vec<_> = masks
.into_iter()
.map(|(target, profiles)| MaskedTestProfiles { target, profiles })
.collect();
rows.sort_unstable_by_key(|row| row.target.0);
self.masked_profiles = rows;
}
fn profiles_for<'a>(&self, storage: &'a [u64], file_id: FileId) -> Option<&'a [u64]> {
let start = (file_id.0 as usize).checked_mul(self.words_per_file)?;
let end = start.checked_add(self.words_per_file)?;
storage.get(start..end)
}
fn masked_profiles_for(&self, file_id: FileId) -> Option<&[u64]> {
self.masked_profiles
.binary_search_by_key(&file_id.0, |row| row.target.0)
.ok()
.map(|index| self.masked_profiles[index].profiles.as_slice())
}
fn covers_reference_path(
&self,
source: FileId,
path: types::ReferencePathId,
paths: &[ReferencePathNode],
routes: &ReferenceRoutes,
) -> bool {
let Some(source_profiles) = self.profiles_for(&self.reachable_profiles, source) else {
return false;
};
for (word_index, &source_word) in source_profiles.iter().enumerate() {
let mut active_profiles = source_word;
if active_profiles == 0 {
continue;
}
let mut next = Some(path);
while let Some(path_id) = next {
let Some(path_node) = paths.get(path_id.index()) else {
return false;
};
next = path_node.parent();
active_profiles = match *path_node {
ReferencePathNode::Hop {
target, mechanism, ..
} => self.active_hop_profiles(target, mechanism, word_index, active_profiles),
ReferencePathNode::Route {
graph,
start,
terminal,
start_mechanism,
..
} => self.active_route_profiles(
routes,
graph,
start,
terminal,
start_mechanism,
word_index,
active_profiles,
),
};
if active_profiles == 0 {
break;
}
}
if active_profiles != 0 {
return true;
}
}
false
}
#[cfg(test)]
fn covers_path<I>(&self, source: FileId, hops: &I) -> bool
where
I: Iterator<Item = (FileId, ModuleLoadMechanism)> + Clone,
{
let Some(source_profiles) = self.profiles_for(&self.reachable_profiles, source) else {
return false;
};
for (word_index, &source_word) in source_profiles.iter().enumerate() {
let mut active_profiles = source_word;
for (target, mechanism) in (*hops).clone() {
active_profiles =
self.active_hop_profiles(target, mechanism, word_index, active_profiles);
if active_profiles == 0 {
break;
}
}
if active_profiles != 0 {
return true;
}
}
false
}
fn active_hop_profiles(
&self,
target: FileId,
mechanism: ModuleLoadMechanism,
word_index: usize,
mut active_profiles: u64,
) -> u64 {
let Some(target_word) = self
.profiles_for(&self.reachable_profiles, target)
.and_then(|profiles| profiles.get(word_index))
else {
return 0;
};
active_profiles &= target_word;
if matches!(mechanism, ModuleLoadMechanism::EsModule)
&& let Some(masked_profiles) = self.masked_profiles_for(target)
{
let Some(masked_word) = masked_profiles.get(word_index) else {
return 0;
};
active_profiles &= !masked_word;
}
active_profiles
}
#[expect(
clippy::too_many_arguments,
reason = "the route identity and profile word form one evaluation contract"
)]
fn active_route_profiles(
&self,
routes: &ReferenceRoutes,
graph_id: types::ReferenceRouteGraphId,
start: ReferenceRouteNodeId,
terminal: ReferenceRouteNodeId,
start_mechanism: Option<ModuleLoadMechanism>,
word_index: usize,
candidate_profiles: u64,
) -> u64 {
let Some(graph) = routes.graphs.get(graph_id.0 as usize) else {
return 0;
};
let node_count = graph.nodes.end.saturating_sub(graph.nodes.start) as usize;
let start_index = start.0 as usize;
let terminal_index = terminal.0 as usize;
if start_index >= node_count || terminal_index >= node_count {
return 0;
}
let mut attempted = vec![0_u64; node_count];
let mut pending = vec![0_u64; node_count];
let mut queued = vec![false; node_count];
let mut queue = std::collections::VecDeque::from([start_index]);
pending[start_index] = candidate_profiles;
queued[start_index] = true;
let mut successful_profiles = 0_u64;
while let Some(local_index) = queue.pop_front() {
queued[local_index] = false;
let incoming = pending[local_index] & !attempted[local_index];
pending[local_index] = 0;
attempted[local_index] |= incoming;
if incoming == 0 {
continue;
}
let Some(node) = routes.nodes.get(graph.nodes.start as usize + local_index) else {
return 0;
};
let active = if local_index == start_index {
start_mechanism.map_or(incoming, |mechanism| {
self.active_hop_profiles(node.target, mechanism, word_index, incoming)
})
} else {
self.active_hop_profiles(node.target, node.mechanism, word_index, incoming)
};
if active == 0 {
continue;
}
if local_index == terminal_index {
successful_profiles |= active;
continue;
}
let Some(successors) = routes
.edges
.get(node.successors.start as usize..node.successors.end as usize)
else {
return 0;
};
for successor in successors {
let successor_index = successor.0 as usize;
if successor_index >= node_count {
return 0;
}
let new_profiles = active & !attempted[successor_index] & !pending[successor_index];
if new_profiles == 0 {
continue;
}
pending[successor_index] |= new_profiles;
if !queued[successor_index] {
queued[successor_index] = true;
queue.push_back(successor_index);
}
}
}
successful_profiles
}
#[cfg(test)]
fn profile_contains(&self, storage: &[u64], file_id: FileId, profile: usize) -> bool {
self.profiles_for(storage, file_id)
.and_then(|words| words.get(profile / u64::BITS as usize))
.is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0)
}
#[cfg(test)]
fn profile_reaches(&self, file_id: FileId, profile: usize) -> bool {
self.profile_contains(&self.reachable_profiles, file_id, profile)
}
#[cfg(test)]
fn profile_masks(&self, file_id: FileId, profile: usize) -> bool {
self.masked_profiles_for(file_id)
.and_then(|words| words.get(profile / u64::BITS as usize))
.is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectImporterSummary {
pub source: FileId,
pub symbols: Vec<ImportedSymbolSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedSymbolSummary {
pub imported: String,
pub local: String,
pub type_only: bool,
}
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<Edge>() == 32);
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
#[cold]
#[inline(never)]
fn propagate_namespace_references(
graph: &mut ModuleGraph,
module_by_id: &FxHashMap<FileId, &ResolvedModule>,
features: build::NamespaceFeatures,
exposed_namespace_targets: &re_exports::ExposedNamespaceTargets,
reference_paths: &mut ReferencePathInterner,
) {
let indexes = namespace_indexes::NamespacePropagationIndexes::new(graph, module_by_id);
if features.has_aliases {
namespace_aliases::propagate_cross_package_aliases(
graph,
module_by_id,
&indexes,
reference_paths,
);
}
if features.has_re_exports {
namespace_re_exports::propagate_namespace_re_exports(
graph,
&indexes,
exposed_namespace_targets,
reference_paths,
);
}
}
impl ModuleGraph {
fn resolve_entry_point_ids(
entry_points: &[EntryPoint],
path_to_id: &FxHashMap<&Path, FileId>,
) -> FxHashSet<FileId> {
entry_points
.iter()
.filter_map(|ep| {
path_to_id.get(ep.path.as_path()).copied().or_else(|| {
dunce::canonicalize(&ep.path)
.ok()
.and_then(|path| path_to_id.get(path.as_path()).copied())
})
})
.collect()
}
pub fn build(
resolved_modules: &[ResolvedModule],
entry_points: &[EntryPoint],
files: &[DiscoveredFile],
) -> Self {
Self::build_with_reachability_roots(
resolved_modules,
entry_points,
entry_points,
&[],
files,
)
}
pub fn build_with_reachability_roots(
resolved_modules: &[ResolvedModule],
entry_points: &[EntryPoint],
runtime_entry_points: &[EntryPoint],
test_entry_points: &[EntryPoint],
files: &[DiscoveredFile],
) -> Self {
Self::build_with_reachability_roots_and_replacements(
resolved_modules,
&[],
entry_points,
runtime_entry_points,
test_entry_points,
files,
)
}
pub fn build_with_reachability_roots_and_replacements(
resolved_modules: &[ResolvedModule],
replaced_module_targets: &[ResolvedReplacedModuleTarget],
entry_points: &[EntryPoint],
runtime_entry_points: &[EntryPoint],
test_entry_points: &[EntryPoint],
files: &[DiscoveredFile],
) -> Self {
let _span = tracing::info_span!("build_graph").entered();
let module_count = files.len();
let max_file_id = files
.iter()
.map(|f| f.id.0 as usize)
.max()
.map_or(0, |m| m + 1);
let total_capacity = max_file_id.max(module_count);
let path_to_id: FxHashMap<&Path, FileId> =
files.iter().map(|f| (f.path.as_path(), f.id)).collect();
let module_by_id: FxHashMap<FileId, &ResolvedModule> =
resolved_modules.iter().map(|m| (m.file_id, m)).collect();
let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
let runtime_entry_point_ids =
Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
for file in files {
if is_declaration_file_path(&file.path) {
entry_point_ids.insert(file.id);
}
}
let (mut graph, namespace_features) = Self::populate_edges(&build::PopulateEdgesInput {
files,
module_by_id: &module_by_id,
entry_point_ids: &entry_point_ids,
runtime_entry_point_ids: &runtime_entry_point_ids,
test_entry_point_ids: &test_entry_point_ids,
module_count,
total_capacity,
});
graph.effective_exports = effective_exports::EffectiveExportIndex::build(resolved_modules);
let test_reachability_plan = reachability::TestReachabilityPlan::new(
&test_entry_point_ids,
replaced_module_targets,
total_capacity,
);
let mut reference_paths =
ReferencePathInterner::new(test_reachability_plan.requires_reference_provenance());
let whole_module_targets =
graph.populate_references(&module_by_id, &entry_point_ids, &mut reference_paths);
let entry_reachable = graph.collect_reachable(&entry_point_ids, total_capacity);
let exposed_namespace_targets = graph.collect_exposed_namespace_targets(
&whole_module_targets,
&entry_reachable,
&module_by_id,
);
if namespace_features.has_aliases || namespace_features.has_re_exports {
propagate_namespace_references(
&mut graph,
&module_by_id,
namespace_features,
&exposed_namespace_targets,
&mut reference_paths,
);
}
graph.mark_reachable(
&entry_reachable,
&entry_point_ids,
&runtime_entry_point_ids,
test_reachability_plan,
total_capacity,
);
graph.re_export_cycles = graph.resolve_re_export_chains(
&module_by_id,
&exposed_namespace_targets,
&mut reference_paths,
);
let finalized_paths = reference_paths.finalize(&mut graph.modules);
graph.reference_paths = finalized_paths.paths;
graph.reference_routes = finalized_paths.routes;
graph
}
#[must_use]
pub const fn module_count(&self) -> usize {
self.modules.len()
}
#[must_use]
pub const fn edge_count(&self) -> usize {
self.edges.len()
}
#[must_use]
pub fn is_test_reachable(&self, file_id: FileId) -> bool {
self.modules
.get(file_id.0 as usize)
.is_some_and(ModuleNode::is_test_reachable)
}
#[must_use]
pub fn is_test_reference_covered(&self, export: &ExportSymbol, reference_index: usize) -> bool {
let Some(reference) = export.references.get(reference_index) else {
return false;
};
if self.test_reachability_index.profile_count == 0 {
return self.is_test_reachable(reference.from_file);
}
let Some(path) = export.reference_path(reference_index) else {
return false;
};
self.test_reachability_index.covers_reference_path(
reference.from_file,
path,
&self.reference_paths,
&self.reference_routes,
)
}
#[must_use]
pub fn is_any_test_reference_covered(&self, export: &ExportSymbol) -> bool {
(0..export.references.len())
.any(|reference_index| self.is_test_reference_covered(export, reference_index))
}
#[cfg(test)]
fn reference_path_hops(
&self,
export: &ExportSymbol,
reference_index: usize,
) -> Vec<(FileId, ModuleLoadMechanism)> {
let mut hops = Vec::new();
let mut next = export.reference_path(reference_index);
while let Some(path_id) = next {
let Some(node) = self.reference_paths.get(path_id.index()) else {
return Vec::new();
};
next = node.parent();
match *node {
ReferencePathNode::Hop {
target, mechanism, ..
} => hops.push((target, mechanism)),
ReferencePathNode::Route {
graph,
start,
terminal,
start_mechanism,
..
} => hops.extend(self.reference_routes.canonical_hops(
graph,
start,
terminal,
start_mechanism,
)),
}
}
hops
}
pub(crate) fn reconstruct_namespace_imported(&mut self) {
let capacity = self
.edges
.iter()
.map(|edge| edge.target.0 as usize + 1)
.max()
.unwrap_or(0)
.max(self.modules.len());
let mut bitset = FixedBitSet::with_capacity(capacity);
for edge in &self.edges {
if edge
.symbols
.iter()
.any(|sym| matches!(sym.imported_name, ImportedName::Namespace))
{
let idx = edge.target.0 as usize;
if idx < capacity {
bitset.insert(idx);
}
}
}
self.namespace_imported = bitset;
}
#[must_use]
pub fn resolve_export(
&self,
file_id: FileId,
name: &str,
namespace: ExportNamespace,
) -> EffectiveExportResolution {
self.effective_exports.resolve(file_id, name, namespace)
}
#[must_use]
pub fn effective_bindings_share_declaration_group(
&self,
left: EffectiveExportBinding,
right: EffectiveExportBinding,
) -> bool {
if left == right || left.origin_file() != right.origin_file() {
return left == right;
}
let Some(right_slot) = right.origin_slot() else {
return false;
};
self.effective_exports
.declaration_group_slots(left)
.contains(&right_slot)
}
#[must_use]
pub fn resolve_export_origin(
&self,
file_id: FileId,
name: &str,
namespace: ExportNamespace,
) -> Option<EffectiveExportOrigin<'_>> {
let EffectiveExportResolution::Unique(binding) =
self.resolve_export(file_id, name, namespace)
else {
return None;
};
self.export_binding_origin(binding)
}
#[must_use]
pub fn effective_export_surface(
&self,
file_id: FileId,
name: &str,
namespace: ExportNamespace,
) -> Option<EffectiveExportSurface<'_>> {
let EffectiveExportResolution::Unique(binding) =
self.resolve_export(file_id, name, namespace)
else {
return None;
};
let module = self.modules.get(file_id.0 as usize)?;
let exact_surface = module.exports.iter().find(|export| {
export.name.matches_str(name)
&& match namespace {
ExportNamespace::Type => export.is_type_only,
ExportNamespace::Value => !export.is_type_only,
}
});
let surface_export = exact_surface.or_else(|| {
module
.exports
.iter()
.find(|export| export.name.matches_str(name))
});
let origin = self.export_binding_origin(binding);
let export = surface_export.or_else(|| origin.map(|o| o.export));
Some(EffectiveExportSurface {
binding,
namespace,
export,
origin,
local_export: surface_export.is_some(),
})
}
#[must_use]
pub fn effective_export_surface_re_export(
&self,
file_id: FileId,
name: &str,
namespace: ExportNamespace,
) -> Option<&ReExportEdge> {
let EffectiveExportResolution::Unique(binding) =
self.resolve_export(file_id, name, namespace)
else {
return None;
};
self.modules
.get(file_id.0 as usize)?
.re_exports
.iter()
.find(|re_export| {
re_export.exported_name == name
&& (namespace == ExportNamespace::Type || !re_export.is_type_only)
&& if re_export.imported_name == "*" {
binding.namespace_source() == Some(re_export.source_file)
} else {
self.resolve_export(
re_export.source_file,
&re_export.imported_name,
namespace,
) == EffectiveExportResolution::Unique(binding)
}
})
}
#[must_use]
pub fn effective_export_surface_references(
&self,
file_id: FileId,
name: &str,
namespace: ExportNamespace,
) -> Vec<&SymbolReference> {
let Some(surface) = self.effective_export_surface(file_id, name, namespace) else {
return Vec::new();
};
let Some(export) = surface.export() else {
return Vec::new();
};
if surface.local_export
|| surface
.origin()
.is_none_or(|origin| origin.file_id() == file_id)
{
return export.references_in(namespace).collect();
}
let mut exposed: FxHashMap<FileId, FxHashSet<String>> = FxHashMap::default();
exposed.entry(file_id).or_default().insert(name.to_string());
for route in self.effective_re_export_routes(file_id, name, namespace) {
exposed
.entry(route.barrel_file())
.or_default()
.insert(route.exported_name().to_string());
}
export
.references
.iter()
.filter(|reference| {
reference.namespace == namespace
&& self.reference_reaches_surface(reference, &exposed, namespace)
})
.collect()
}
fn reference_reaches_surface(
&self,
reference: &SymbolReference,
exposed: &FxHashMap<FileId, FxHashSet<String>>,
namespace: ExportNamespace,
) -> bool {
if reference.kind == ReferenceKind::ReExport && exposed.contains_key(&reference.from_file) {
return true;
}
self.outgoing_symbol_edges(reference.from_file)
.any(|(target, symbols)| {
let Some(names) = exposed.get(&target) else {
return false;
};
symbols.iter().any(|symbol| {
symbol.import_span == reference.import_span
&& (namespace == ExportNamespace::Type
|| !symbol.is_type_only
|| symbol.is_value_bearing_ambient_star())
&& match &symbol.imported_name {
ImportedName::Named(imported) => names.contains(imported.as_str()),
ImportedName::Default => names.contains("default"),
ImportedName::Namespace => true,
ImportedName::SideEffect => false,
}
})
})
}
#[must_use]
pub fn export_binding_origin(
&self,
binding: EffectiveExportBinding,
) -> Option<EffectiveExportOrigin<'_>> {
let origin_file = binding.origin_file();
let export = self
.modules
.get(origin_file.0 as usize)?
.exports
.get(binding.origin_slot()?)?;
Some(EffectiveExportOrigin {
file_id: origin_file,
export,
})
}
#[must_use]
pub fn unique_export_bindings(
&self,
file_id: FileId,
namespace: ExportNamespace,
) -> FxHashSet<EffectiveExportBinding> {
self.effective_exports.unique_bindings(file_id, namespace)
}
#[must_use]
pub fn importer_connects_export_origin(
&self,
importer: FileId,
source: FileId,
name: &str,
namespace: ExportNamespace,
) -> bool {
let Some(importer_module) = self.modules.get(importer.0 as usize) else {
return false;
};
let re_export_count = importer_module
.re_exports
.iter()
.filter(|re_export| re_export.source_file == source)
.count();
if self.edges[importer_module.edge_range.clone()]
.iter()
.any(|edge| edge.target == source && edge.symbols.len() > re_export_count)
{
return true;
}
importer_module.re_exports.iter().any(|re_export| {
if re_export.source_file != source
|| (namespace == ExportNamespace::Value && re_export.is_type_only)
{
return false;
}
let exported_name = if re_export.imported_name == "*" {
if re_export.exported_name != "*" || name == "default" {
return false;
}
name
} else {
if re_export.imported_name != name {
return false;
}
&re_export.exported_name
};
self.effective_exports.contributes_through(
importer,
exported_name,
source,
name,
namespace,
)
})
}
#[must_use]
pub fn has_namespace_import(&self, file_id: FileId) -> bool {
let idx = file_id.0 as usize;
if idx >= self.namespace_imported.len() {
return false;
}
self.namespace_imported.contains(idx)
}
#[must_use]
pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
let idx = file_id.0 as usize;
if idx >= self.modules.len() {
return Vec::new();
}
let range = &self.modules[idx].edge_range;
self.edges[range.clone()].iter().map(|e| e.target).collect()
}
pub fn outgoing_symbol_edges(
&self,
file_id: FileId,
) -> impl Iterator<Item = (FileId, &[ImportedSymbol])> + '_ {
let idx = file_id.0 as usize;
let range = if idx < self.modules.len() {
self.modules[idx].edge_range.clone()
} else {
0..0
};
self.edges[range]
.iter()
.map(|edge| (edge.target, edge.symbols.as_slice()))
}
#[must_use]
pub fn importers_of(&self, target: FileId) -> &[FileId] {
self.reverse_deps
.get(target.0 as usize)
.map_or(&[], Vec::as_slice)
}
#[must_use]
pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
let Some(importers) = self.reverse_deps.get(target.0 as usize) else {
return Vec::new();
};
let mut summaries = Vec::new();
for &source in importers {
let idx = source.0 as usize;
let Some(source_node) = self.modules.get(idx) else {
continue;
};
let mut symbols = Vec::new();
for edge in &self.edges[source_node.edge_range.clone()] {
if edge.target != target {
continue;
}
symbols.extend(edge.symbols.iter().map(|symbol| ImportedSymbolSummary {
imported: imported_name_label(&symbol.imported_name),
local: symbol.local_name.clone(),
type_only: symbol.is_type_only,
}));
}
symbols.sort_by(|a, b| {
a.imported
.cmp(&b.imported)
.then_with(|| a.local.cmp(&b.local))
.then_with(|| a.type_only.cmp(&b.type_only))
});
symbols.dedup();
summaries.push(DirectImporterSummary { source, symbols });
}
summaries.sort_by_key(|summary| summary.source.0);
summaries
}
#[must_use]
pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
let idx = source.0 as usize;
if idx >= self.modules.len() {
return None;
}
let range = &self.modules[idx].edge_range;
for edge in &self.edges[range.clone()] {
if edge.target == target {
return edge
.symbols
.iter()
.find(|s| !s.is_type_only)
.or_else(|| edge.symbols.first())
.map(|s| s.import_span.start);
}
}
None
}
pub fn outgoing_edge_summaries(
&self,
file_id: FileId,
) -> impl Iterator<Item = (FileId, bool, Option<u32>)> + '_ {
let idx = file_id.0 as usize;
let range = if idx < self.modules.len() {
self.modules[idx].edge_range.clone()
} else {
0..0
};
self.edges[range].iter().map(|edge| {
let all_type_only =
!edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
let span = edge
.symbols
.iter()
.find(|s| !s.is_type_only)
.or_else(|| edge.symbols.first())
.map(|s| s.import_span.start);
(edge.target, all_type_only, span)
})
}
pub fn outgoing_edge_summaries_with_exclusions<'a>(
&'a self,
file_id: FileId,
excluded_span_starts: &'a FxHashSet<u32>,
) -> impl Iterator<Item = (FileId, bool, Option<u32>, bool)> + 'a {
let idx = file_id.0 as usize;
let range = if idx < self.modules.len() {
self.modules[idx].edge_range.clone()
} else {
0..0
};
self.edges[range].iter().map(move |edge| {
let all_type_only =
!edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
let span = edge
.symbols
.iter()
.find(|s| !s.is_type_only)
.or_else(|| edge.symbols.first())
.map(|s| s.import_span.start);
let mut value_symbols = edge.symbols.iter().filter(|s| !s.is_type_only).peekable();
let all_client_only = value_symbols.peek().is_some()
&& value_symbols.all(|s| excluded_span_starts.contains(&s.import_span.start));
(edge.target, all_type_only, span, all_client_only)
})
}
}
fn imported_name_label(name: &ImportedName) -> String {
match name {
ImportedName::Named(name) => name.clone(),
ImportedName::Default => "default".to_string(),
ImportedName::Namespace => "*".to_string(),
ImportedName::SideEffect => "side-effect".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
use std::path::PathBuf;
fn build_simple_graph() -> ModuleGraph {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/src/entry.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/src/utils.ts"),
size_bytes: 50,
},
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/src/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/src/entry.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./utils".to_string(),
imported_name: ImportedName::Named("foo".to_string()),
local_name: "foo".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/src/utils.ts"),
exports: vec![
fallow_types::extract::ExportInfo {
name: ExportName::Named("foo".to_string()),
local_name: Some("foo".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,
},
fallow_types::extract::ExportInfo {
name: ExportName::Named("bar".to_string()),
local_name: Some("bar".to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: oxc_span::Span::new(25, 45),
members: vec![],
is_side_effect_used: false,
super_class: None,
},
]
.into(),
..Default::default()
},
];
ModuleGraph::build(&resolved_modules, &entry_points, &files)
}
#[test]
fn graph_module_count() {
let graph = build_simple_graph();
assert_eq!(graph.module_count(), 2);
}
#[test]
fn graph_edge_count() {
let graph = build_simple_graph();
assert_eq!(graph.edge_count(), 1);
}
#[test]
fn graph_entry_point_is_reachable() {
let graph = build_simple_graph();
assert!(graph.modules[0].is_entry_point());
assert!(graph.modules[0].is_reachable());
}
#[test]
fn graph_imported_module_is_reachable() {
let graph = build_simple_graph();
assert!(!graph.modules[1].is_entry_point());
assert!(graph.modules[1].is_reachable());
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
would obscure the cross-role assertions"
)]
fn graph_distinguishes_runtime_test_and_support_reachability() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/src/main.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/src/runtime-only.ts"),
size_bytes: 50,
},
DiscoveredFile {
id: FileId(2),
path: PathBuf::from("/project/tests/app.test.ts"),
size_bytes: 50,
},
DiscoveredFile {
id: FileId(3),
path: PathBuf::from("/project/tests/setup.ts"),
size_bytes: 50,
},
DiscoveredFile {
id: FileId(4),
path: PathBuf::from("/project/src/covered.ts"),
size_bytes: 50,
},
];
let all_entry_points = vec![
EntryPoint {
path: PathBuf::from("/project/src/main.ts"),
source: EntryPointSource::PackageJsonMain,
},
EntryPoint {
path: PathBuf::from("/project/tests/app.test.ts"),
source: EntryPointSource::TestFile,
},
EntryPoint {
path: PathBuf::from("/project/tests/setup.ts"),
source: EntryPointSource::Plugin {
name: "vitest".to_string(),
},
},
];
let runtime_entry_points = vec![EntryPoint {
path: PathBuf::from("/project/src/main.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let test_entry_points = vec![EntryPoint {
path: PathBuf::from("/project/tests/app.test.ts"),
source: EntryPointSource::TestFile,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/src/main.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./runtime-only".to_string(),
imported_name: ImportedName::Named("runtimeOnly".to_string()),
local_name: "runtimeOnly".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/src/runtime-only.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Named("runtimeOnly".to_string()),
local_name: Some("runtimeOnly".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,
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/tests/app.test.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "../src/covered".to_string(),
imported_name: ImportedName::Named("covered".to_string()),
local_name: "covered".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(4)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: PathBuf::from("/project/tests/setup.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "../src/runtime-only".to_string(),
imported_name: ImportedName::Named("runtimeOnly".to_string()),
local_name: "runtimeOnly".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(4),
path: PathBuf::from("/project/src/covered.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Named("covered".to_string()),
local_name: Some("covered".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,
}]
.into(),
..Default::default()
},
];
let graph = ModuleGraph::build_with_reachability_roots(
&resolved_modules,
&all_entry_points,
&runtime_entry_points,
&test_entry_points,
&files,
);
assert!(graph.modules[1].is_reachable());
assert!(graph.modules[1].is_runtime_reachable());
assert!(
!graph.modules[1].is_test_reachable(),
"support roots should not make runtime-only modules test reachable"
);
assert!(graph.modules[4].is_reachable());
assert!(graph.modules[4].is_test_reachable());
assert!(
!graph.modules[4].is_runtime_reachable(),
"test-only reachability should stay separate from runtime roots"
);
}
#[test]
fn graph_export_has_reference() {
let graph = build_simple_graph();
let utils = &graph.modules[1];
let foo_export = utils
.exports
.iter()
.find(|e| e.name.to_string() == "foo")
.unwrap();
assert!(
!foo_export.references.is_empty(),
"foo should have references"
);
}
#[test]
fn graph_unused_export_no_reference() {
let graph = build_simple_graph();
let utils = &graph.modules[1];
let bar_export = utils
.exports
.iter()
.find(|e| e.name.to_string() == "bar")
.unwrap();
assert!(
bar_export.references.is_empty(),
"bar should have no references"
);
}
#[test]
fn graph_no_namespace_import() {
let graph = build_simple_graph();
assert!(!graph.has_namespace_import(FileId(0)));
assert!(!graph.has_namespace_import(FileId(1)));
}
#[test]
fn graph_has_namespace_import() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
size_bytes: 50,
},
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./utils".to_string(),
imported_name: ImportedName::Namespace,
local_name: "utils".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Named("foo".to_string()),
local_name: Some("foo".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,
}]
.into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
assert!(
graph.has_namespace_import(FileId(1)),
"utils should have namespace import"
);
}
#[test]
fn graph_has_namespace_import_out_of_bounds() {
let graph = build_simple_graph();
assert!(!graph.has_namespace_import(FileId(999)));
}
#[test]
fn reconstruct_namespace_imported_matches_fresh_build() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
size_bytes: 50,
},
DiscoveredFile {
id: FileId(2),
path: PathBuf::from("/project/named-only.ts"),
size_bytes: 50,
},
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
resolved_imports: vec![
ResolvedImport {
info: ImportInfo {
source: "./utils".to_string(),
imported_name: ImportedName::Namespace,
local_name: "utils".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
},
ResolvedImport {
info: ImportInfo {
source: "./named-only".to_string(),
imported_name: ImportedName::Named("foo".to_string()),
local_name: "foo".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(11, 20),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(2)),
},
],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/named-only.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Named("foo".to_string()),
local_name: Some("foo".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,
}]
.into(),
..Default::default()
},
];
let mut graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let fresh = graph.namespace_imported.clone();
assert!(graph.has_namespace_import(FileId(1)));
assert!(!graph.has_namespace_import(FileId(2)));
graph.namespace_imported = FixedBitSet::default();
graph.reconstruct_namespace_imported();
assert_eq!(
graph.namespace_imported, fresh,
"reconstructed namespace_imported must equal the fresh-built bitset"
);
assert!(graph.has_namespace_import(FileId(1)));
assert!(!graph.has_namespace_import(FileId(2)));
}
#[test]
fn graph_unreachable_module() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
size_bytes: 50,
},
DiscoveredFile {
id: FileId(2),
path: PathBuf::from("/project/orphan.ts"),
size_bytes: 30,
},
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./utils".to_string(),
imported_name: ImportedName::Named("foo".to_string()),
local_name: "foo".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Named("foo".to_string()),
local_name: Some("foo".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,
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/orphan.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Named("orphan".to_string()),
local_name: Some("orphan".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,
}]
.into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
assert!(graph.modules[0].is_reachable(), "entry should be reachable");
assert!(graph.modules[1].is_reachable(), "utils should be reachable");
assert!(
!graph.modules[2].is_reachable(),
"orphan should NOT be reachable"
);
}
#[test]
fn graph_package_usage_tracked() {
let files = vec![DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
}];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
exports: vec![].into(),
re_exports: vec![],
resolved_imports: vec![
ResolvedImport {
info: ImportInfo {
source: "react".to_string(),
imported_name: ImportedName::Default,
local_name: "React".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::NpmPackage("react".to_string()),
},
ResolvedImport {
info: ImportInfo {
source: "lodash".to_string(),
imported_name: ImportedName::Named("merge".to_string()),
local_name: "merge".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(15, 30),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::NpmPackage("lodash".to_string()),
},
],
..Default::default()
}];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
assert!(graph.package_usage.contains_key("react"));
assert!(graph.package_usage.contains_key("lodash"));
assert!(!graph.package_usage.contains_key("express"));
}
#[test]
fn graph_empty() {
let graph = ModuleGraph::build(&[], &[], &[]);
assert_eq!(graph.module_count(), 0);
assert_eq!(graph.edge_count(), 0);
}
#[test]
fn graph_postcard_round_trip_is_lossless() {
let graph = build_simple_graph();
let encoded = postcard::to_allocvec(&graph).expect("encode graph");
let mut decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
decoded.reconstruct_namespace_imported();
assert_eq!(decoded.module_count(), graph.module_count());
assert_eq!(decoded.edge_count(), graph.edge_count());
assert_eq!(decoded.namespace_imported, graph.namespace_imported);
let utils = &decoded.modules[1];
let foo = utils
.exports
.iter()
.find(|e| e.name.to_string() == "foo")
.expect("foo export survives round-trip");
assert!(!foo.references.is_empty());
let bar = utils
.exports
.iter()
.find(|e| e.name.to_string() == "bar")
.expect("bar export survives round-trip");
assert!(bar.references.is_empty());
assert!(decoded.modules[0].is_entry_point());
assert!(decoded.modules[0].is_reachable());
assert!(decoded.modules[1].is_reachable());
assert_eq!(decoded.entry_points, graph.entry_points);
}
#[test]
fn graph_cjs_exports_tracked() {
let files = vec![DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
}];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
has_cjs_exports: true,
has_angular_component_template_url: false,
..Default::default()
}];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
assert!(graph.modules[0].has_cjs_exports());
}
#[test]
fn graph_edges_for_returns_targets() {
let graph = build_simple_graph();
let targets = graph.edges_for(FileId(0));
assert_eq!(targets, vec![FileId(1)]);
}
#[test]
fn graph_edges_for_no_imports() {
let graph = build_simple_graph();
let targets = graph.edges_for(FileId(1));
assert!(targets.is_empty());
}
#[test]
fn graph_edges_for_out_of_bounds() {
let graph = build_simple_graph();
let targets = graph.edges_for(FileId(999));
assert!(targets.is_empty());
}
#[test]
fn graph_direct_importer_summaries_include_symbols() {
let graph = build_simple_graph();
let summaries = graph.direct_importer_summaries(FileId(1));
assert_eq!(
summaries,
vec![DirectImporterSummary {
source: FileId(0),
symbols: vec![ImportedSymbolSummary {
imported: "foo".to_string(),
local: "foo".to_string(),
type_only: false,
}],
}]
);
}
#[test]
fn graph_find_import_span_start_found() {
let graph = build_simple_graph();
let span_start = graph.find_import_span_start(FileId(0), FileId(1));
assert!(span_start.is_some());
assert_eq!(span_start.unwrap(), 0);
}
#[test]
fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
size_bytes: 50,
},
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
resolved_imports: vec![
ResolvedImport {
info: ImportInfo {
source: "./utils".to_string(),
imported_name: ImportedName::Named("Foo".to_string()),
local_name: "Foo".to_string(),
is_type_only: true,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(10, 20),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
},
ResolvedImport {
info: ImportInfo {
source: "./utils".to_string(),
imported_name: ImportedName::Named("foo".to_string()),
local_name: "foo".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(50, 60),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
},
],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
}
#[test]
fn graph_find_import_span_start_wrong_target() {
let graph = build_simple_graph();
let span_start = graph.find_import_span_start(FileId(0), FileId(0));
assert!(span_start.is_none());
}
#[test]
fn graph_find_import_span_start_source_out_of_bounds() {
let graph = build_simple_graph();
let span_start = graph.find_import_span_start(FileId(999), FileId(1));
assert!(span_start.is_none());
}
#[test]
fn graph_find_import_span_start_no_edges() {
let graph = build_simple_graph();
let span_start = graph.find_import_span_start(FileId(1), FileId(0));
assert!(span_start.is_none());
}
#[test]
fn graph_reverse_deps_populated() {
let graph = build_simple_graph();
assert!(graph.reverse_deps[1].contains(&FileId(0)));
assert!(graph.reverse_deps[0].is_empty());
}
#[test]
fn graph_type_only_package_usage_tracked() {
let files = vec![DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
}];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
resolved_imports: vec![
ResolvedImport {
info: ImportInfo {
source: "react".to_string(),
imported_name: ImportedName::Named("FC".to_string()),
local_name: "FC".to_string(),
is_type_only: true,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::NpmPackage("react".to_string()),
},
ResolvedImport {
info: ImportInfo {
source: "react".to_string(),
imported_name: ImportedName::Named("useState".to_string()),
local_name: "useState".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(15, 30),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::NpmPackage("react".to_string()),
},
],
..Default::default()
}];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
assert!(graph.package_usage.contains_key("react"));
assert!(graph.type_only_package_usage.contains_key("react"));
}
#[test]
fn graph_default_import_reference() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
size_bytes: 50,
},
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./utils".to_string(),
imported_name: ImportedName::Default,
local_name: "Utils".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/utils.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Default,
local_name: None,
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,
}]
.into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let utils = &graph.modules[1];
let default_export = utils
.exports
.iter()
.find(|e| matches!(e.name, ExportName::Default))
.unwrap();
assert!(!default_export.references.is_empty());
assert_eq!(
default_export.references[0].kind,
ReferenceKind::DefaultImport
);
}
#[test]
fn graph_side_effect_import_no_export_reference() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/styles.ts"),
size_bytes: 50,
},
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/entry.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/entry.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./styles".to_string(),
imported_name: ImportedName::SideEffect,
local_name: String::new(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/styles.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Named("primaryColor".to_string()),
local_name: Some("primaryColor".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,
}]
.into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
assert_eq!(graph.edge_count(), 1);
let styles = &graph.modules[1];
assert!(styles.is_reachable());
let export = &styles.exports[0];
assert!(
export.references.is_empty(),
"side-effect import should not reference named exports"
);
let encoded = postcard::to_allocvec(&graph).expect("encode graph");
let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
assert_eq!(decoded.edge_count(), 1);
assert!(decoded.modules[1].is_reachable());
assert!(decoded.modules[1].exports[0].references.is_empty());
}
#[test]
fn graph_multiple_entry_points() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/project/main.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/project/worker.ts"),
size_bytes: 100,
},
DiscoveredFile {
id: FileId(2),
path: PathBuf::from("/project/shared.ts"),
size_bytes: 50,
},
];
let entry_points = vec![
EntryPoint {
path: PathBuf::from("/project/main.ts"),
source: EntryPointSource::PackageJsonMain,
},
EntryPoint {
path: PathBuf::from("/project/worker.ts"),
source: EntryPointSource::PackageJsonMain,
},
];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/main.ts"),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./shared".to_string(),
imported_name: ImportedName::Named("helper".to_string()),
local_name: "helper".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(2)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/worker.ts"),
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/shared.ts"),
exports: vec![fallow_types::extract::ExportInfo {
name: ExportName::Named("helper".to_string()),
local_name: Some("helper".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,
}]
.into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
assert!(graph.modules[0].is_entry_point());
assert!(graph.modules[1].is_entry_point());
assert!(!graph.modules[2].is_entry_point());
assert!(graph.modules[0].is_reachable());
assert!(graph.modules[1].is_reachable());
assert!(graph.modules[2].is_reachable());
}
}