diff --git a/src/graph.rs b/src/graph.rs index 251c446cf..7a3f55f83 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -35,6 +35,7 @@ use std::cmp::Ordering; use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::VecDeque; use std::fmt; use std::pin::Pin; use std::sync::Arc; @@ -454,7 +455,7 @@ fn is_false(v: &bool) -> bool { !v } -#[derive(Debug, Default, Clone, Serialize)] +#[derive(Debug, Default, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Dependency { #[serde( @@ -628,7 +629,7 @@ fn to_result<'a>( /// module graph without requiring the dependencies to be analyzed. This is /// intended to be used for importing type dependencies or other externally /// defined dependencies, like JSX runtimes. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GraphImport { /// A map of resolved dependencies, where the key is the value originally /// provided for the import and the value is the resolved dependency. @@ -678,6 +679,162 @@ pub struct BuildOptions<'a> { pub reporter: Option<&'a dyn Reporter>, } +#[derive(Debug, Copy, Clone)] +pub enum ModuleEntryRef<'a> { + Module(&'a Module), + Err(&'a ModuleGraphError), + Redirect(&'a ModuleSpecifier), +} + +#[derive(Debug, Copy, Clone)] +pub struct WalkOptions { + pub follow_dynamic: bool, + pub follow_type_only: bool, + pub check_js: bool, +} + +impl Default for WalkOptions { + fn default() -> Self { + Self { + follow_dynamic: true, + follow_type_only: true, + check_js: true, + } + } +} + +pub struct ModuleEntryIterator<'a> { + graph: &'a ModuleGraph, + seen: HashSet<&'a ModuleSpecifier>, + visiting: VecDeque<&'a ModuleSpecifier>, + follow_dynamic: bool, + follow_type_only: bool, + check_js: bool, +} + +impl<'a> ModuleEntryIterator<'a> { + fn new( + graph: &'a ModuleGraph, + roots: &[&'a ModuleSpecifier], + options: WalkOptions, + ) -> Self { + let mut seen = HashSet::<&'a ModuleSpecifier>::with_capacity( + graph.roots.len() + graph.redirects.len(), + ); + let mut visiting = VecDeque::<&'a ModuleSpecifier>::new(); + for root in roots { + seen.insert(root); + visiting.push_back(root); + } + for (_, dep) in graph.imports.values().flat_map(|i| &i.dependencies) { + let mut resolutions = Vec::with_capacity(2); + resolutions.push(&dep.maybe_code); + if options.follow_type_only { + resolutions.push(&dep.maybe_type); + } + #[allow(clippy::manual_flatten)] + for resolved in resolutions { + if let Resolved::Ok { specifier, .. } = resolved { + if !seen.contains(specifier) { + seen.insert(specifier); + visiting.push_front(specifier); + } + } + } + } + Self { + graph, + seen, + visiting, + follow_dynamic: options.follow_dynamic, + follow_type_only: options.follow_type_only, + check_js: options.check_js, + } + } +} + +impl<'a> Iterator for ModuleEntryIterator<'a> { + type Item = (&'a ModuleSpecifier, ModuleEntryRef<'a>); + + fn next(&mut self) -> Option { + let (specifier, module_entry) = loop { + let specifier = self.visiting.pop_front()?; + match self.graph.module_slots.get_key_value(specifier) { + Some((specifier, module_slot)) => { + match module_slot { + ModuleSlot::Pending => { + // ignore + } + ModuleSlot::Module(module) => { + break (specifier, ModuleEntryRef::Module(module)) + } + ModuleSlot::Err(err) => { + break (specifier, ModuleEntryRef::Err(err)) + } + } + } + None => { + if let Some((specifier, to)) = + self.graph.redirects.get_key_value(specifier) + { + break (specifier, ModuleEntryRef::Redirect(to)); + } + } + } + }; + + match &module_entry { + ModuleEntryRef::Module(module) => { + let check_types = (self.check_js + || !matches!( + module.media_type, + MediaType::JavaScript + | MediaType::Mjs + | MediaType::Cjs + | MediaType::Jsx + )) + && self.follow_type_only; + if check_types { + if let Some((_, Resolved::Ok { specifier, .. })) = + &module.maybe_types_dependency + { + if !self.seen.contains(specifier) { + self.seen.insert(specifier); + self.visiting.push_front(specifier); + } + } + } + for dep in module.dependencies.values().rev() { + if !dep.is_dynamic || self.follow_dynamic { + let mut resolutions = vec![&dep.maybe_code]; + if check_types { + resolutions.push(&dep.maybe_type); + } + #[allow(clippy::manual_flatten)] + for resolved in resolutions { + if let Resolved::Ok { specifier, .. } = resolved { + if !self.seen.contains(specifier) { + self.seen.insert(specifier); + self.visiting.push_front(specifier); + } + } + } + } + } + } + ModuleEntryRef::Err(_) => {} + ModuleEntryRef::Redirect(specifier) => { + if !self.seen.contains(specifier) { + self.seen.insert(specifier); + self.visiting.push_front(specifier); + } + } + } + + Some((specifier, module_entry)) + } +} + /// The structure which represents a module graph, which can be serialized as /// well as "printed". The roots of the graph represent the "starting" point /// which can be located in the module "slots" in the graph. The graph also @@ -723,6 +880,52 @@ impl ModuleGraph { .await } + /// Creates a new cloned module graph from the provided roots. + pub fn segment(&self, roots: &[&ModuleSpecifier]) -> Self { + let mut new_graph = ModuleGraph::new(self.graph_kind); + let entries = self.walk( + roots, + WalkOptions { + follow_dynamic: true, + follow_type_only: true, + check_js: true, + }, + ); + + for (specifier, module_entry) in entries { + match module_entry { + ModuleEntryRef::Module(module) => { + new_graph + .module_slots + .insert(specifier.clone(), ModuleSlot::Module(module.clone())); + } + ModuleEntryRef::Err(err) => { + new_graph + .module_slots + .insert(specifier.clone(), ModuleSlot::Err(err.clone())); + } + ModuleEntryRef::Redirect(specifier_to) => { + new_graph + .redirects + .insert(specifier.clone(), specifier_to.clone()); + } + } + } + new_graph.imports = self.imports.clone(); + new_graph.roots = roots.iter().map(|r| (*r).to_owned()).collect(); + + new_graph + } + + /// Iterates over all the module entries in the module graph searching from the provided roots. + pub fn walk<'a>( + &'a self, + roots: &[&'a ModuleSpecifier], + options: WalkOptions, + ) -> ModuleEntryIterator<'a> { + ModuleEntryIterator::new(self, roots, options) + } + /// Returns `true` if the specifier resolves to a module within a graph, /// otherwise returns `false`. pub fn contains(&self, specifier: &ModuleSpecifier) -> bool { @@ -1389,7 +1592,7 @@ fn is_untyped(media_type: &MediaType) -> bool { } /// The kind of module graph. -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum GraphKind { /// All types of dependencies should be analyzed and included in the graph. All, diff --git a/src/lib.rs b/src/lib.rs index f30133073..81dde2ec9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,6 +49,7 @@ pub use module_specifier::resolve_import; pub use module_specifier::ModuleSpecifier; pub use module_specifier::SpecifierError; +#[derive(Debug, Clone)] pub struct ReferrerImports { /// The referrer to resolve the imports from. pub referrer: ModuleSpecifier, @@ -109,6 +110,7 @@ pub fn parse_module_from_ast( mod tests { use super::*; use crate::graph::Resolved; + use crate::graph::WalkOptions; use pretty_assertions::assert_eq; use serde_json::json; use source::tests::MockResolver; @@ -116,6 +118,7 @@ mod tests { use source::MemoryLoader; use source::Source; use std::cell::RefCell; + use std::collections::BTreeMap; type Sources<'a> = Vec<(&'a str, Source<&'a str>)>; @@ -2988,4 +2991,381 @@ export function a(a: A): B { }) ); } + + #[tokio::test] + async fn test_segment_graph() { + let mut loader = setup( + vec![ + ( + "file:///a/test01.ts", + Source::Module { + specifier: "file:///a/test01.ts", + maybe_headers: None, + content: r#"import * as b from "./test02.ts"; import "https://example.com/a.ts";"#, + }, + ), + ( + "file:///a/test02.ts", + Source::Module { + specifier: "file:///a/test02.ts", + maybe_headers: None, + content: r#"export const b = "b";"#, + }, + ), + ( + "https://example.com/a.ts", + Source::Module { + specifier: "https://example.com/a.ts", + maybe_headers: None, + content: r#"import * as c from "./c";"#, + }, + ), + ( + "https://example.com/c", + Source::Module { + specifier: "https://example.com/c.ts", + maybe_headers: None, + content: r#"export const c = "c";"#, + }, + ), + ( + "https://example.com/jsx-runtime", + Source::Module { + specifier: "https://example.com/jsx-runtime", + maybe_headers: Some(vec![ + ("content-type", "application/javascript"), + ("x-typescript-types", "./jsx-runtime.d.ts"), + ]), + content: r#"export const a = "a";"#, + }, + ), + ( + "https://example.com/jsx-runtime.d.ts", + Source::Module { + specifier: "https://example.com/jsx-runtime.d.ts", + maybe_headers: Some(vec![( + "content-type", + "application/typescript", + )]), + content: r#"export const a: "a";"#, + }, + ), + ], + vec![], + ); + let roots = vec![ModuleSpecifier::parse("file:///a/test01.ts").unwrap()]; + let mut graph = ModuleGraph::default(); + let config_specifier = + ModuleSpecifier::parse("file:///a/tsconfig.json").unwrap(); + let imports = vec![ReferrerImports { + referrer: config_specifier.clone(), + imports: vec!["https://example.com/jsx-runtime".to_string()], + }]; + graph + .build( + roots.clone(), + &mut loader, + BuildOptions { + imports: imports.clone(), + ..Default::default() + }, + ) + .await; + assert!(graph.valid().is_ok()); + assert_eq!(graph.module_slots.len(), 6); + assert_eq!(graph.redirects.len(), 1); + + let example_a_url = + ModuleSpecifier::parse("https://example.com/a.ts").unwrap(); + let graph = graph.segment(&[&example_a_url]); + assert_eq!(graph.roots, vec![example_a_url]); + // should get the redirect + assert_eq!( + graph.redirects, + BTreeMap::from([( + ModuleSpecifier::parse("https://example.com/c").unwrap(), + ModuleSpecifier::parse("https://example.com/c.ts").unwrap(), + )]) + ); + + // should copy over the imports + assert_eq!(graph.imports.len(), 1); + + assert!(graph + .contains(&ModuleSpecifier::parse("https://example.com/a.ts").unwrap())); + assert!(graph + .contains(&ModuleSpecifier::parse("https://example.com/c.ts").unwrap())); + + assert_eq!( + graph.resolve_dependency( + "https://example.com/jsx-runtime", + &config_specifier, + false + ), + Some(&ModuleSpecifier::parse("https://example.com/jsx-runtime").unwrap()) + ); + assert_eq!( + graph.resolve_dependency( + "https://example.com/jsx-runtime", + &config_specifier, + true + ), + Some( + &ModuleSpecifier::parse("https://example.com/jsx-runtime.d.ts") + .unwrap() + ) + ); + } + + #[tokio::test] + async fn test_walk() { + let mut loader = setup( + vec![ + ( + "file:///a/test01.ts", + Source::Module { + specifier: "file:///a/test01.ts", + maybe_headers: None, + content: r#"import * as b from "./test02.ts"; import "https://example.com/a.ts"; import "./test04.js"; await import("./test03.ts");"#, + }, + ), + ( + "file:///a/test02.ts", + Source::Module { + specifier: "file:///a/test02.ts", + maybe_headers: None, + content: r#"export const b = "b";"#, + }, + ), + ( + "file:///a/test03.ts", + Source::Module { + specifier: "file:///a/test03.ts", + maybe_headers: None, + content: r#"export const c = "c";"#, + }, + ), + ( + "file:///a/test04.js", + Source::Module { + specifier: "file:///a/test04.js", + maybe_headers: None, + content: r#"/// \nexport const d = "d";"#, + }, + ), + ( + "file:///a/test04.d.ts", + Source::Module { + specifier: "file:///a/test04.d.ts", + maybe_headers: None, + content: r#"export const d: "d";"#, + }, + ), + ( + "https://example.com/a.ts", + Source::Module { + specifier: "https://example.com/a.ts", + maybe_headers: None, + content: r#"import * as c from "./c";"#, + }, + ), + ( + "https://example.com/c", + Source::Module { + specifier: "https://example.com/c.ts", + maybe_headers: None, + content: r#"export const c = "c";"#, + }, + ), + ( + "https://example.com/jsx-runtime", + Source::Module { + specifier: "https://example.com/jsx-runtime", + maybe_headers: Some(vec![ + ("content-type", "application/javascript"), + ("x-typescript-types", "./jsx-runtime.d.ts"), + ]), + content: r#"export const a = "a";"#, + }, + ), + ( + "https://example.com/jsx-runtime.d.ts", + Source::Module { + specifier: "https://example.com/jsx-runtime.d.ts", + maybe_headers: Some(vec![( + "content-type", + "application/typescript", + )]), + content: r#"export const a: "a";"#, + }, + ), + ], + vec![], + ); + let root = ModuleSpecifier::parse("file:///a/test01.ts").unwrap(); + let mut graph = ModuleGraph::default(); + let config_specifier = + ModuleSpecifier::parse("file:///a/tsconfig.json").unwrap(); + let imports = vec![ReferrerImports { + referrer: config_specifier.clone(), + imports: vec!["https://example.com/jsx-runtime".to_string()], + }]; + graph + .build( + vec![root.clone()], + &mut loader, + BuildOptions { + imports: imports.clone(), + ..Default::default() + }, + ) + .await; + assert!(graph.valid().is_ok()); + + // all true + let result = graph.walk( + &[&root], + WalkOptions { + check_js: true, + follow_dynamic: true, + follow_type_only: true, + }, + ); + assert_eq!( + result + .map(|(specifier, _)| specifier.to_string()) + .collect::>(), + vec![ + "https://example.com/jsx-runtime", + "https://example.com/jsx-runtime.d.ts", + "file:///a/test01.ts", + "file:///a/test02.ts", + "file:///a/test03.ts", + "file:///a/test04.js", + "file:///a/test04.d.ts", + "https://example.com/a.ts", + "https://example.com/c", + "https://example.com/c.ts", + ] + ); + + // all false + let result = graph.walk( + &[&root], + WalkOptions { + check_js: false, + follow_dynamic: false, + follow_type_only: false, + }, + ); + assert_eq!( + result + .map(|(specifier, _)| specifier.to_string()) + .collect::>(), + vec![ + "file:///a/test01.ts", + "file:///a/test02.ts", + "file:///a/test04.js", // no types + "https://example.com/a.ts", + "https://example.com/c", + "https://example.com/c.ts", + ] + ); + // dynamic true + let result = graph.walk( + &[&root], + WalkOptions { + check_js: false, + follow_dynamic: true, + follow_type_only: false, + }, + ); + assert_eq!( + result + .map(|(specifier, _)| specifier.to_string()) + .collect::>(), + vec![ + "file:///a/test01.ts", + "file:///a/test02.ts", + "file:///a/test03.ts", + "file:///a/test04.js", + "https://example.com/a.ts", + "https://example.com/c", + "https://example.com/c.ts", + ] + ); + + // check_js true (won't have any effect since follow_type_only is false) + let result = graph.walk( + &[&root], + WalkOptions { + check_js: true, + follow_dynamic: false, + follow_type_only: false, + }, + ); + assert_eq!( + result + .map(|(specifier, _)| specifier.to_string()) + .collect::>(), + vec![ + "file:///a/test01.ts", + "file:///a/test02.ts", + "file:///a/test04.js", + "https://example.com/a.ts", + "https://example.com/c", + "https://example.com/c.ts", + ] + ); + + // follow_type_only true + let result = graph.walk( + &[&root], + WalkOptions { + check_js: false, + follow_dynamic: false, + follow_type_only: true, + }, + ); + assert_eq!( + result + .map(|(specifier, _)| specifier.to_string()) + .collect::>(), + vec![ + "https://example.com/jsx-runtime", + "file:///a/test01.ts", + "file:///a/test02.ts", + "file:///a/test04.js", + "https://example.com/a.ts", + "https://example.com/c", + "https://example.com/c.ts", + ] + ); + + // check_js true, follow_type_only true + let result = graph.walk( + &[&root], + WalkOptions { + check_js: true, + follow_dynamic: false, + follow_type_only: true, + }, + ); + assert_eq!( + result + .map(|(specifier, _)| specifier.to_string()) + .collect::>(), + vec![ + "https://example.com/jsx-runtime", + "https://example.com/jsx-runtime.d.ts", + "file:///a/test01.ts", + "file:///a/test02.ts", + "file:///a/test04.js", + "file:///a/test04.d.ts", + "https://example.com/a.ts", + "https://example.com/c", + "https://example.com/c.ts", + ] + ); + } }