use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use skif_core::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use globset::{GlobBuilder, GlobSetBuilder};
use skif_core::FileSystem;
use skif_core::timing::{self, Tally};
use skif_js_syntax::{
ImportKind, ModuleInfo, ParseOptions, RegisteredComponent, SyntaxError, is_source_query, is_stylesheet,
parse_cached, registered_components, registered_name,
};
use skif_workspace::{PackageId, map_ordered};
use crate::compilers::{self, Compiler, reader_path};
use crate::components::{self, Registered};
use crate::discover::{Candidate, is_package_manifest, relative};
use crate::resolve::{EXTENSION_ALIASES, ModuleResolver, Resolved};
use crate::setup::Setup;
use crate::shared_modules::{ModuleParser, Served};
use crate::source_scan::add_textual_imports;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ModuleId(u32);
impl ModuleId {
#[must_use]
pub const fn index(self) -> usize {
self.0 as usize
}
fn from_index(index: usize) -> Self {
Self(u32::try_from(index).unwrap_or(u32::MAX))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Module(ModuleId),
Modules(Vec<ModuleId>),
External(String),
Builtin,
Asset,
Ignored,
Unresolved,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedImport {
pub import: usize,
pub target: Target,
pub package: Option<String>,
pub type_only: bool,
pub component: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[allow(clippy::struct_excessive_bools, reason = "independent facts about one file")]
pub struct Module {
pub id: ModuleId,
pub abs: PathBuf,
pub path: PathBuf,
pub owner: PackageId,
pub in_project: bool,
pub ignored: bool,
pub foreign: bool,
pub is_entry: bool,
pub is_build_source: bool,
pub info: ModuleInfo,
pub parse_error: Option<String>,
pub imports: Vec<ResolvedImport>,
}
impl Module {
#[must_use]
pub fn reexport_source(&self, specifier: &str) -> Option<ModuleId> {
self.imports.iter().find_map(|resolved| {
let import = self.info.imports.get(resolved.import)?;
if import.kind != ImportKind::ExportFrom || import.specifier != specifier {
return None;
}
match resolved.target {
Target::Module(id) => Some(id),
_ => None,
}
})
}
}
fn project_dir_candidates<'p>(abs: &'p Path, base: &Path) -> Option<Vec<&'p Path>> {
if !abs.starts_with(base) {
return None;
}
let between: Vec<&Path> = abs.ancestors().skip(1).take_while(|dir| *dir != base).collect();
let installed = between
.iter()
.any(|dir| dir.file_name().is_some_and(|name| name == "node_modules"));
(!installed).then_some(between)
}
#[derive(Debug, Clone, Default)]
pub struct ModuleGraph {
modules: Vec<Module>,
index: HashMap<PathBuf, ModuleId>,
imported_by: Vec<Vec<ModuleId>>,
}
impl ModuleGraph {
#[must_use]
pub fn modules(&self) -> &[Module] {
&self.modules
}
#[must_use]
pub fn module(&self, id: ModuleId) -> &Module {
&self.modules[id.index()]
}
#[must_use]
pub fn id_of(&self, abs: &Path) -> Option<ModuleId> {
self.index.get(abs).copied()
}
#[must_use]
pub fn imported_by(&self, id: ModuleId) -> &[ModuleId] {
self.imported_by.get(id.index()).map_or(&[], Vec::as_slice)
}
}
#[derive(Default)]
struct Parsed {
info: ModuleInfo,
error: Option<String>,
imports: Vec<RawImport>,
components: Vec<(String, Registered)>,
registrations: Vec<usize>,
}
struct RawImport {
import: usize,
target: RawTarget,
package: Option<String>,
type_only: bool,
reach: Reach,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Reach {
RootDirs(PathBuf),
Relative,
Other,
}
enum RawTarget {
File(PathBuf),
Files(Vec<PathBuf>),
External(String),
Builtin,
Asset,
Ignored,
Unresolved,
}
pub(crate) struct Inputs<'a> {
pub fs: &'a dyn FileSystem,
pub resolver: &'a ModuleResolver,
pub root: &'a Path,
pub candidates: &'a [Candidate],
pub links: &'a HashMap<PathBuf, PathBuf>,
pub setup: &'a Setup,
pub sequential: bool,
pub modules: Option<&'a ModuleParser<'a>>,
}
fn normalize(path: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
parts.pop();
}
other => parts.push(other),
}
}
parts.join("/")
}
fn expand_glob(importer: &Path, pattern: &str, inputs: &Inputs<'_>) -> Vec<PathBuf> {
if pattern.starts_with('!') {
return Vec::new();
}
let joined = if let Some(rooted) = pattern.strip_prefix('/') {
normalize(rooted)
} else {
let dir = importer
.parent()
.and_then(|parent| relative(parent, inputs.root))
.unwrap_or_default();
normalize(&format!("{dir}/{pattern}"))
};
let aliases = EXTENSION_ALIASES
.iter()
.filter_map(|(from, to)| Some((joined.strip_suffix(from)?, *to)))
.flat_map(|(stem, to)| to.iter().map(move |alias| format!("{stem}{alias}")));
let mut set = GlobSetBuilder::new();
for pattern in std::iter::once(joined.clone()).chain(aliases) {
let Ok(glob) = GlobBuilder::new(&pattern).literal_separator(true).build() else {
return Vec::new();
};
set.add(glob);
}
let Ok(matcher) = set.build() else {
return Vec::new();
};
inputs
.candidates
.iter()
.filter(|candidate| candidate.abs != importer && matcher.is_match(&candidate.rel))
.map(|candidate| candidate.abs.clone())
.collect()
}
fn generated_sources(importer: &Path, dir: &Path, recursive: bool, inputs: &Inputs<'_>) -> Vec<PathBuf> {
let owner = inputs
.candidates
.iter()
.find(|candidate| candidate.abs == importer)
.map(|candidate| candidate.owner);
inputs
.candidates
.iter()
.filter(|candidate| {
candidate.abs != importer
&& owner.is_none_or(|owner| candidate.owner == owner)
&& if recursive {
candidate.abs.starts_with(dir)
} else {
candidate.abs.parent() == Some(dir)
}
})
.map(|candidate| candidate.abs.clone())
.collect()
}
static READ_TIME: Tally = Tally::new();
static PARSE_TIME: Tally = Tally::new();
static WAIT_TIME: Tally = Tally::new();
static RESOLVE_TIME: Tally = Tally::new();
static CACHE_HITS: AtomicU64 = AtomicU64::new(0);
static CACHE_MISSES: AtomicU64 = AtomicU64::new(0);
static CACHE_WAITS: AtomicU64 = AtomicU64::new(0);
pub(crate) fn report_timings(root: &Path) {
READ_TIME.report(format_args!("js read {}", root.display()));
PARSE_TIME.report(format_args!("js parse {}", root.display()));
RESOLVE_TIME.report(format_args!("js resolve {}", root.display()));
let hits = CACHE_HITS.swap(0, Ordering::Relaxed);
let misses = CACHE_MISSES.swap(0, Ordering::Relaxed);
let waits = CACHE_WAITS.swap(0, Ordering::Relaxed);
if hits + misses + waits > 0 {
timing::note(format_args!(
"js module cache {}: {hits} hits, {misses} misses, {waits} waits",
root.display()
));
}
if waits > 0 {
WAIT_TIME.report(format_args!("js parse wait {}", root.display()));
}
}
fn parse_source(modules: Option<&ModuleParser<'_>>, path: &Path, source: &str) -> Result<ModuleInfo, SyntaxError> {
let options = ParseOptions::default();
let Some(modules) = modules else {
return PARSE_TIME.time(|| parse_cached(None, path, source, &options));
};
if !timing::enabled() {
return modules.parse(path, source, options).0;
}
let start = Instant::now();
let (parsed, served) = modules.parse(path, source, options);
let (tally, counter) = match served {
Served::Uncached => (&PARSE_TIME, None),
Served::Hit => (&PARSE_TIME, Some(&CACHE_HITS)),
Served::Parsed => (&PARSE_TIME, Some(&CACHE_MISSES)),
Served::Waited => (&WAIT_TIME, Some(&CACHE_WAITS)),
};
tally.add(start.elapsed());
if let Some(counter) = counter {
counter.fetch_add(1, Ordering::Relaxed);
}
parsed
}
fn process(path: &Path, inputs: &Inputs<'_>) -> Parsed {
let failed = |message: String| Parsed {
error: Some(message),
..Parsed::default()
};
let source = match READ_TIME.time(|| inputs.fs.read_to_string(path)) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Parsed::default(),
Err(error) => return failed(error.to_string()),
};
let compiler = inputs.setup.compilers.of(path);
let read_as = match compiler {
Some(Compiler::Reader(reader)) => Cow::Owned(reader_path(path, reader)),
_ => Cow::Borrowed(path),
};
let style = is_stylesheet(&read_as);
let component = read_as
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| matches!(extension, "vue" | "svelte" | "astro"));
let parsed = match compiler {
Some(Compiler::Patterns(patterns)) => Ok(compilers::extract(patterns, &source)),
_ => parse_source(inputs.modules, &read_as, &source),
};
let mut info = match parsed {
Ok(info) => info,
Err(error) => return failed(error.to_string()),
};
let error = info.fatal.then(|| {
info.parse_diagnostics
.first()
.map_or_else(|| "syntax error".to_owned(), |issue| issue.message.clone())
});
let parsed_imports = info.imports.len();
let document = read_as
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("mdx"));
if !style && !document && !matches!(compiler, Some(Compiler::Patterns(_))) {
add_textual_imports(&mut info, &source);
}
let imports: Vec<RawImport> = info
.imports
.iter()
.enumerate()
.map(|(index, import)| {
let type_only = import.kind == ImportKind::TypeOnly
|| (!import.names.is_empty() && import.names.iter().all(|name| name.is_type));
if import.kind == ImportKind::SourceGlob {
return RawImport {
import: index,
target: RawTarget::Asset,
package: None,
type_only,
reach: Reach::Other,
};
}
if import.kind == ImportKind::ImportMetaGlob {
return RawImport {
import: index,
target: RawTarget::Files(expand_glob(path, &import.specifier, inputs)),
package: None,
type_only,
reach: Reach::Other,
};
}
let outcome = RESOLVE_TIME.time(|| {
if style {
inputs.resolver.resolve_style(path, &import.specifier)
} else {
let found = inputs.resolver.resolve(path, &import.specifier);
if component && matches!(found.target, Resolved::Unresolved) {
let styled = inputs.resolver.resolve_style(path, &import.specifier);
if matches!(styled.target, Resolved::File(_)) {
return styled;
}
}
found
}
});
let reads_source = import
.specifier
.split_once('?')
.is_some_and(|(_, query)| is_source_query(query));
let reach = match outcome.root_dirs {
Some(root_dir) => Reach::RootDirs(root_dir),
None if import.specifier.starts_with('.') => Reach::Relative,
None => Reach::Other,
};
let target = match outcome.target {
Resolved::File(_) if reads_source => RawTarget::Asset,
Resolved::File(file) => RawTarget::File(file),
Resolved::Asset => RawTarget::Asset,
Resolved::External(name) => RawTarget::External(name),
Resolved::Builtin => RawTarget::Builtin,
Resolved::Ignored => RawTarget::Ignored,
Resolved::Generated { dir, recursive } => {
RawTarget::Files(generated_sources(path, &dir, recursive, inputs))
}
Resolved::Unresolved if index >= parsed_imports => RawTarget::Asset,
Resolved::Unresolved => RawTarget::Unresolved,
};
RawImport {
import: index,
target,
package: outcome.package,
type_only,
reach,
}
})
.collect();
let (components, registrations) = if components::is_generated(path, &source) {
registered(&info, &imports, ®istered_components(&source))
} else {
(Vec::new(), Vec::new())
};
Parsed {
info,
error,
imports,
components,
registrations,
}
}
fn registered(
info: &ModuleInfo,
imports: &[RawImport],
registered: &[RegisteredComponent],
) -> (Vec<(String, Registered)>, Vec<usize>) {
let mut found = Vec::new();
let mut positions = Vec::new();
for (position, raw) in imports.iter().enumerate() {
let (Some(import), RawTarget::File(file)) = (info.imports.get(raw.import), &raw.target) else {
continue;
};
let before = found.len();
found.extend(
registered
.iter()
.filter(|component| component.specifier == import.specifier)
.map(|component| {
let registration = Registered {
specifier: component.specifier.clone(),
file: file.clone(),
};
(component.name.clone(), registration)
}),
);
if found.len() > before {
positions.push(position);
}
}
(found, positions)
}
struct Builder<'a> {
inputs: &'a Inputs<'a>,
candidate_index: HashMap<&'a Path, usize>,
package_dirs: Vec<(PathBuf, PackageId)>,
modules: Vec<Module>,
index: HashMap<PathBuf, ModuleId>,
project_dirs: HashMap<PathBuf, bool>,
components: BTreeMap<String, Vec<Registered>>,
linked: BTreeSet<(ModuleId, PathBuf)>,
linked_upto: usize,
relink: bool,
untagged: HashMap<PackageId, bool>,
adopted: HashMap<ModuleId, PathBuf>,
}
struct Adoption {
owner: PackageId,
root_dir: PathBuf,
}
impl<'a> Builder<'a> {
fn new(inputs: &'a Inputs<'a>) -> Self {
let candidate_index = inputs
.candidates
.iter()
.enumerate()
.map(|(position, candidate)| (candidate.abs.as_path(), position))
.collect();
let mut package_dirs: Vec<(PathBuf, PackageId)> = inputs
.setup
.packages
.iter()
.flatten()
.map(|package| (package.dir.clone(), package.id))
.collect();
package_dirs.sort_by_key(|(dir, _)| std::cmp::Reverse(dir.components().count()));
Self {
inputs,
candidate_index,
package_dirs,
modules: Vec::new(),
index: HashMap::default(),
project_dirs: HashMap::default(),
components: BTreeMap::new(),
linked: BTreeSet::new(),
linked_upto: 0,
relink: false,
untagged: HashMap::default(),
adopted: HashMap::default(),
}
}
fn owner_of(&self, abs: &Path) -> PackageId {
self.package_dirs
.iter()
.find(|(dir, _)| abs.starts_with(dir))
.map_or(PackageId::ROOT, |(_, id)| *id)
}
fn in_other_project(&mut self, abs: &Path, owner: PackageId) -> bool {
let inputs = self.inputs;
let base = self
.package_dirs
.iter()
.find(|(_, id)| *id == owner)
.map_or(inputs.root, |(dir, _)| dir.as_path());
let Some(between) = project_dir_candidates(abs, base) else {
return false;
};
between.into_iter().any(|dir| {
*self
.project_dirs
.entry(dir.to_path_buf())
.or_insert_with(|| inputs.fs.is_file(&dir.join("package.json")) && is_package_manifest(inputs.fs, dir))
})
}
fn located_owner(&self, abs: &Path) -> PackageId {
self.candidate_index.get(abs).map_or_else(
|| self.owner_of(abs),
|position| self.inputs.candidates[*position].owner,
)
}
fn adoptable(&self, abs: &Path) -> bool {
let abs = self.inputs.links.get(abs).map_or(abs, PathBuf::as_path);
!self.candidate_index.contains_key(abs) && self.owner_of(abs) == PackageId::ROOT
}
fn adopter(&self, importer: ModuleId, raw: &RawImport) -> Option<Adoption> {
let RawTarget::File(file) = &raw.target else {
return None;
};
let root_dir = match &raw.reach {
Reach::RootDirs(root_dir) => root_dir,
Reach::Relative => self.adopted.get(&importer)?,
Reach::Other => return None,
};
let owner = self.modules[importer.index()].owner;
(owner != PackageId::ROOT && file.starts_with(root_dir) && self.adoptable(file)).then(|| Adoption {
owner,
root_dir: root_dir.clone(),
})
}
fn intern(&mut self, abs: &Path) -> (ModuleId, bool) {
self.intern_owned(abs, None)
}
fn intern_owned(&mut self, abs: &Path, adoption: Option<Adoption>) -> (ModuleId, bool) {
let inputs = self.inputs;
let abs = inputs.links.get(abs).map_or(abs, PathBuf::as_path);
if let Some(id) = self.index.get(abs) {
return (*id, false);
}
let id = ModuleId::from_index(self.modules.len());
let candidate = self
.candidate_index
.get(abs)
.map(|position| &inputs.candidates[*position]);
let located = self.located_owner(abs);
let owner = adoption.as_ref().map_or(located, |adoption| adoption.owner);
if let Some(adoption) = adoption {
self.adopted.insert(id, adoption.root_dir);
}
let rel = relative(abs, inputs.root).unwrap_or_else(|| abs.to_string_lossy().into_owned());
let setup = inputs.setup.package(owner);
let package_rel = setup.and_then(|setup| relative(abs, &setup.dir));
let foreign = candidate.is_none() && (!abs.starts_with(inputs.root) || self.in_other_project(abs, owner));
let ignored_by = |package: PackageId| {
inputs
.setup
.package(package)
.is_some_and(|setup| relative(abs, &setup.dir).is_some_and(|rel| setup.ignore.matches_any(&rel)))
};
let ignored = foreign
|| inputs.setup.global_ignore.matches_any(&rel)
|| ignored_by(owner)
|| (owner != located && ignored_by(located));
let in_project = candidate.is_some()
&& setup
.zip(package_rel.as_deref())
.is_some_and(|(setup, rel)| setup.project.is_match(rel));
self.modules.push(Module {
id,
abs: abs.to_path_buf(),
path: PathBuf::from(rel),
owner,
in_project,
ignored,
foreign,
is_entry: false,
is_build_source: false,
info: ModuleInfo::default(),
parse_error: None,
imports: Vec::new(),
});
self.index.insert(abs.to_path_buf(), id);
(id, true)
}
fn lower(&mut self, target: RawTarget, adoption: Option<Adoption>, next: &mut Vec<ModuleId>) -> Target {
match target {
RawTarget::File(path) => {
let (id, fresh) = self.intern_owned(&path, adoption);
if fresh {
next.push(id);
}
Target::Module(id)
}
RawTarget::Files(paths) => Target::Modules(
paths
.iter()
.map(|path| {
let (id, fresh) = self.intern(path);
if fresh {
next.push(id);
}
id
})
.collect(),
),
RawTarget::External(name) => Target::External(name),
RawTarget::Builtin => Target::Builtin,
RawTarget::Asset => Target::Asset,
RawTarget::Ignored => Target::Ignored,
RawTarget::Unresolved => Target::Unresolved,
}
}
fn links_by_tag(&mut self, id: ModuleId) -> bool {
let inputs = self.inputs;
let owner = self.modules[id.index()].owner;
let untagged = *self.untagged.entry(owner).or_insert_with(|| {
inputs
.setup
.package(owner)
.is_none_or(|package| components::may_be_used_untagged(inputs.fs, package, inputs.candidates))
});
!untagged
}
fn register(&mut self, components: Vec<(String, Registered)>) {
for (name, registration) in components {
let files = self.components.entry(name).or_default();
if !files.iter().any(|known| known.file == registration.file) {
files.push(registration);
self.relink = true;
}
}
}
fn link_components(&mut self) -> Vec<ModuleId> {
let mut fresh = Vec::new();
let start = if std::mem::take(&mut self.relink) {
0
} else {
self.linked_upto
};
let end = self.modules.len();
self.linked_upto = end;
if self.components.is_empty() {
return fresh;
}
for index in start..end {
let module = &self.modules[index];
let mut links: Vec<(String, Registered, bool)> = Vec::new();
for tag in &module.info.template_tags {
let Some(name) = registered_name(tag, |name| self.components.contains_key(name)) else {
continue;
};
let Some(files) = self.components.get(&name) else {
continue;
};
let mut first = true;
for registration in files.iter().filter(|registration| registration.file != module.abs) {
links.push((name.clone(), registration.clone(), first));
first = false;
}
}
let id = ModuleId::from_index(index);
for (name, registration, first) in links {
if !self.linked.insert((id, registration.file.clone())) {
continue;
}
let target = self.lower(RawTarget::File(registration.file), None, &mut fresh);
let module = &mut self.modules[index];
let import = module.info.imports.len();
module
.info
.imports
.push(components::import(&name, ®istration.specifier, first));
module.imports.push(ResolvedImport {
import,
target,
package: None,
type_only: false,
component: true,
});
}
}
fresh
}
}
pub(crate) fn build(inputs: &Inputs<'_>, entries: &[PathBuf], build_sources: &[PathBuf]) -> ModuleGraph {
let mut builder = Builder::new(inputs);
let mut frontier = Vec::new();
for entry in entries {
let (id, fresh) = builder.intern(entry);
builder.modules[id.index()].is_entry = true;
if fresh {
frontier.push(id);
}
}
for source in build_sources {
let (id, fresh) = builder.intern(source);
if fresh {
builder.modules[id.index()].is_build_source = true;
frontier.push(id);
}
}
while !frontier.is_empty() {
let paths: Vec<&Path> = frontier
.iter()
.map(|id| builder.modules[id.index()].abs.as_path())
.collect();
let parsed: Vec<Parsed> = map_ordered(&paths, inputs.sequential, |path| process(path, inputs));
let mut next = Vec::new();
for (id, mut parsed) in frontier.iter().zip(parsed) {
if !parsed.components.is_empty() && builder.links_by_tag(*id) {
for position in &parsed.registrations {
parsed.imports[*position].target = RawTarget::Ignored;
}
builder.register(std::mem::take(&mut parsed.components));
}
let imports = parsed
.imports
.into_iter()
.map(|raw| {
let adoption = builder.adopter(*id, &raw);
ResolvedImport {
import: raw.import,
target: builder.lower(raw.target, adoption, &mut next),
package: raw.package,
type_only: raw.type_only,
component: false,
}
})
.collect();
let module = &mut builder.modules[id.index()];
module.info = parsed.info;
module.parse_error = parsed.error;
module.imports = imports;
}
frontier = next;
if frontier.is_empty() {
frontier = builder.link_components();
}
}
ModuleGraph {
imported_by: importers(&builder.modules),
modules: builder.modules,
index: builder.index,
}
}
fn importers(modules: &[Module]) -> Vec<Vec<ModuleId>> {
let mut imported_by: Vec<Vec<ModuleId>> = vec![Vec::new(); modules.len()];
for module in modules {
for import in &module.imports {
let targets: &[ModuleId] = match &import.target {
Target::Module(id) => std::slice::from_ref(id),
Target::Modules(ids) => ids,
_ => &[],
};
for target in targets {
let importers = &mut imported_by[target.index()];
if importers.last() != Some(&module.id) {
importers.push(module.id);
}
}
}
}
imported_by
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_directories_between_a_file_and_its_package_may_be_projects_unless_installed() {
let base = Path::new("/ws");
assert_eq!(
project_dir_candidates(Path::new("/ws/build/npm/postinstall.ts"), base),
Some(vec![Path::new("/ws/build/npm"), Path::new("/ws/build")])
);
assert_eq!(project_dir_candidates(Path::new("/ws/main.ts"), base), Some(Vec::new()));
assert_eq!(
project_dir_candidates(Path::new("/ws/node_modules/tool/cli.js"), base),
None
);
assert_eq!(
project_dir_candidates(Path::new("/ws/a/node_modules/@s/t/x.js"), base),
None
);
assert_eq!(project_dir_candidates(Path::new("/other/x.ts"), base), None);
}
fn module(index: usize, targets: Vec<Target>) -> Module {
Module {
id: ModuleId::from_index(index),
abs: PathBuf::from(format!("/ws/{index}.ts")),
path: PathBuf::from(format!("{index}.ts")),
owner: PackageId::ROOT,
in_project: true,
ignored: false,
foreign: false,
is_entry: false,
is_build_source: false,
info: ModuleInfo::default(),
parse_error: None,
imports: targets
.into_iter()
.enumerate()
.map(|(import, target)| ResolvedImport {
import,
target,
package: None,
type_only: false,
component: false,
})
.collect(),
}
}
#[test]
fn importers_are_sorted_and_listed_once_however_often_a_module_imports() {
let id = ModuleId::from_index;
let modules = vec![
module(0, vec![Target::Module(id(3))]),
module(1, vec![Target::Module(id(0)), Target::External("react".to_owned())]),
module(
2,
vec![
Target::Module(id(0)),
Target::Modules(vec![id(0), id(1)]),
Target::Module(id(2)),
Target::Unresolved,
],
),
module(3, vec![Target::Module(id(0)), Target::Module(id(0))]),
];
assert_eq!(
importers(&modules),
vec![vec![id(1), id(2), id(3)], vec![id(2)], vec![id(2)], vec![id(0)]]
);
}
}