diff --git a/Cargo.lock b/Cargo.lock index b0bc7079..8ece5003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -411,6 +411,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + [[package]] name = "color-eyre" version = "0.6.5" @@ -3170,11 +3210,21 @@ dependencies = [ "wax", ] +[[package]] +name = "vite_task_bin" +version = "0.1.0" +dependencies = [ + "clap", + "vite_str", + "vite_task_graph", +] + [[package]] name = "vite_task_graph" version = "0.1.0" dependencies = [ "anyhow", + "clap", "copy_dir", "insta", "monostate", @@ -3184,6 +3234,7 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", + "toml", "vec1", "vite_path", "vite_str", diff --git a/Cargo.toml b/Cargo.toml index cc1061af..824adf82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ bstr = { version = "1.12.0", default-features = false, features = ["alloc", "std bumpalo = { version = "3.17.0", features = ["allocator-api2"] } bytemuck = { version = "1.23.0", features = ["extern_crate_alloc", "must_cast"] } cc = "1.2.39" +clap = "4.5.53" color-eyre = "0.6.5" compact_str = "0.9.0" const_format = "0.2.34" diff --git a/crates/vite_path/src/absolute.rs b/crates/vite_path/src/absolute.rs index fc85af8e..89beb90c 100644 --- a/crates/vite_path/src/absolute.rs +++ b/crates/vite_path/src/absolute.rs @@ -38,6 +38,14 @@ impl Hash for AbsolutePath { } } +impl From<&AbsolutePath> for Arc { + fn from(path: &AbsolutePath) -> Self { + let arc: Arc = path.0.into(); + let arc_raw = Arc::into_raw(arc) as *const AbsolutePath; + unsafe { Self::from_raw(arc_raw) } + } +} + impl AbsolutePath { /// Creates a [`AbsolutePath`] if the give path is absolute. pub fn new + ?Sized>(path: &P) -> Option<&Self> { diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml new file mode 100644 index 00000000..58b13455 --- /dev/null +++ b/crates/vite_task_bin/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "vite_task_bin" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[[bin]] +name = "vite" +path = "src/vite.rs" + +[dependencies] +clap = { workspace = true, features = ["derive"] } +vite_str = { workspace = true } +vite_task_graph = { path = "../vite_task_graph" } + +[lints] +workspace = true diff --git a/crates/vite_task_bin/src/vite.rs b/crates/vite_task_bin/src/vite.rs new file mode 100644 index 00000000..9b675bfa --- /dev/null +++ b/crates/vite_task_bin/src/vite.rs @@ -0,0 +1,19 @@ +use clap::Parser; +use vite_str::Str; + +#[derive(Parser)] +enum SubCommand { + /// Run tasks + Run { + #[clap(flatten)] + query: vite_task_graph::query::cli::CLITaskQuery, + + /// Additional arguments to pass to the tasks + #[clap(last = true)] + args: Vec, + }, +} + +fn main() { + let _subcommand = SubCommand::parse(); +} diff --git a/crates/vite_task_graph/Cargo.toml b/crates/vite_task_graph/Cargo.toml index 03cee953..15ebfb2a 100644 --- a/crates/vite_task_graph/Cargo.toml +++ b/crates/vite_task_graph/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } monostate = "1.0.2" petgraph = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -24,6 +25,7 @@ copy_dir = { workspace = true } insta = { workspace = true, features = ["glob", "json"] } tempfile = { workspace = true } tokio = { workspace = true, features = ["fs", "rt-multi-thread"] } +toml = { workspace = true } [lints] workspace = true diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index d1537ae2..e4b29fbb 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -1,21 +1,27 @@ pub mod config; pub mod loader; +mod package_graph; +pub mod query; +mod specifier; use std::{ collections::{HashMap, hash_map::Entry}, + convert::Infallible, sync::Arc, }; use config::{ResolvedUserTaskConfig, UserConfigFile}; +use package_graph::IndexedPackageGraph; use petgraph::{ graph::{DefaultIx, DiGraph, EdgeIndex, IndexType, NodeIndex}, visit::{Control, DfsEvent, depth_first_search}, }; use serde::Serialize; +use specifier::TaskSpecifier; use vec1::smallvec_v1::SmallVec1; -use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_path::AbsolutePath; use vite_str::Str; -use vite_workspace::{DependencyType, PackageInfo, PackageIx, PackageNodeIndex, WorkspaceRoot}; +use vite_workspace::{PackageNodeIndex, WorkspaceRoot}; #[derive(Debug, Clone, Copy, Serialize)] enum TaskDependencyTypeInner { @@ -35,11 +41,11 @@ pub struct TaskDependencyType(TaskDependencyTypeInner); // It hides `TaskDependencyTypeInner` and only expose `is_explicit`/`is_topological` // to avoid incorrectly matching only Explicit variant to check if it's explicit. impl TaskDependencyType { - pub fn is_explicit(&self) -> bool { + pub fn is_explicit(self) -> bool { matches!(self.0, TaskDependencyTypeInner::Explicit | TaskDependencyTypeInner::Both) } - pub fn is_topological(&self) -> bool { + pub fn is_topological(self) -> bool { matches!(self.0, TaskDependencyTypeInner::Topological | TaskDependencyTypeInner::Both) } } @@ -64,16 +70,10 @@ pub struct TaskNode { /// The unique id of this task pub task_id: TaskId, - /// The name of the package where this task is defined. - /// It's used for matching task specifiers ('packageName#taskName') - /// - /// If package.json doesn't have a name field, this will be "", so the task can be matched by `#taskName`. - pub package_name: Str, - /// The resolved configuration of this task. /// /// This contains information affecting how the task is spawn, - /// whereas `task_id` and `package_name` are for looking up the task. + /// whereas `task_id` is for looking up the task. /// /// However, it does not contain external factors like additional args from cli and env vars. pub resolved_config: ResolvedUserTaskConfig, @@ -109,19 +109,30 @@ pub enum TaskGraphLoadError { }, } +/// Error when looking up a task by its specifier. +/// +/// It's generic over `UnknownPackageError`, which is the error type when looking up a task without a package name and without a package origin. +/// +/// - When the specifier is from `dependOn` of a known task, `UnknownPackageError` is `Infallible` because the origin package is always known. +/// - When the specifier is from a CLI command, `UnknownPackageError` can be a real error type in case cwd is not in any package. #[derive(Debug, thiserror::Error)] -pub enum SpecifierLookupError { +pub enum SpecifierLookupError { #[error("Package '{package_name}' is ambiguous among multiple packages: {package_paths:?}")] - AmbiguousPackageName { package_name: Str, package_paths: Box<[RelativePathBuf]> }, + AmbiguousPackageName { package_name: Str, package_paths: Box<[Arc]> }, #[error("Package '{package_name}' not found")] PackageNameNotFound { package_name: Str }, #[error("Task '{task_name}' not found in package {package_name}")] - TaskNameNotFound { package_name: Str, task_name: Str }, + TaskNameNotFound { package_name: Str, task_name: Str, package_index: PackageNodeIndex }, + + #[error( + "Nowhere to look for task '{task_name}' because the package is unknown: {unspecifier_package_error}" + )] + PackageUnknown { unspecifier_package_error: PackageUnknownError, task_name: Str }, } -/// newtype of `DefaultIx` for indices in package graphs +/// newtype of `DefaultIx` for indices in task graphs #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct TaskIx(DefaultIx); unsafe impl IndexType for TaskIx { @@ -141,26 +152,23 @@ unsafe impl IndexType for TaskIx { pub type TaskNodeIndex = NodeIndex; pub type TaskEdgeIndex = EdgeIndex; -/// Full task graph of a workspace. +/// Full task graph of a workspace, with necessary HashMaps for quick task lookup /// /// It's immutable after created. The task nodes contain resolved task configurations and their dependencies. /// External factors (e.g. additional args from cli, current working directory, environmental variables) are not stored here. -pub struct TaskGraph { +pub struct IndexedTaskGraph { task_graph: DiGraph, - /// Preserve the package graph for displaying purpose: - /// `self.task_graph` refers packages via PackageNodeIndex. To display package names and paths, we need to lookup them in package_graph. - package_graph: DiGraph, - - /// Grouping package indices by their package names. - /// Due to rare but possible name conflicts in monorepos, we use `SmallVec1` to store multiple dirs for same name. - package_indices_by_name: HashMap>, + /// Preserve the package graph for two purposes: + /// - `self.task_graph` refers packages via PackageNodeIndex. To display package names and paths, we need to lookup them in package_graph. + /// - To find nearest topological tasks when the starting package itself doesn't contain the task with the given name. + indexed_package_graph: IndexedPackageGraph, /// task indices by task id for quick lookup node_indices_by_task_id: HashMap, } -impl TaskGraph { +impl IndexedTaskGraph { /// Load the task graph from a discovered workspace using the provided config loader. pub async fn load( workspace_root: WorkspaceRoot<'_>, @@ -213,11 +221,7 @@ impl TaskGraph { task_name: task_name.clone(), })?; - let task_node = TaskNode { - task_id, - package_name: package.package_json.name.clone(), - resolved_config, - }; + let task_node = TaskNode { task_id, resolved_config }; let node_index = task_graph.add_node(task_node); dependency_specifiers_with_task_node_indices @@ -231,11 +235,7 @@ impl TaskGraph { &package_dir, package_json_script, ); - task_graph.add_node(TaskNode { - task_id, - package_name: package.package_json.name.clone(), - resolved_config, - }); + task_graph.add_node(TaskNode { task_id, resolved_config }); } } @@ -253,12 +253,12 @@ impl TaskGraph { } } - // Grouping package dirs by their package names. - let mut package_dirs_by_name: HashMap> = + // Grouping package indices by their package names. + let mut package_indices_by_name: HashMap> = HashMap::new(); for package_index in package_graph.node_indices() { let package = &package_graph[package_index]; - match package_dirs_by_name.entry(package.package_json.name.clone()) { + match package_indices_by_name.entry(package.package_json.name.clone()) { Entry::Vacant(vacant) => { vacant.insert(SmallVec1::new(package_index)); } @@ -271,9 +271,8 @@ impl TaskGraph { // Construct `Self` with task_graph with all task nodes ready and indexed, but no edges. let mut me = Self { task_graph, - package_graph, + indexed_package_graph: IndexedPackageGraph::index(package_graph), node_indices_by_task_id, - package_indices_by_name: package_dirs_by_name, }; // Add explicit dependencies @@ -283,7 +282,10 @@ impl TaskGraph { for specifier in dependency_specifiers.iter().cloned() { let to_node_index = me - .get_task_index_by_specifier(&specifier, from_task_id.package_index) + .get_task_index_by_specifier::( + TaskSpecifier::parse_raw(&specifier), + || Ok(from_task_id.package_index), + ) .map_err(|error| TaskGraphLoadError::DependencySpecifierLookupError { error, specifier, @@ -298,155 +300,157 @@ impl TaskGraph { } // Add topological dependencies based on package dependencies - let mut topological_edges = Vec::<(TaskNodeIndex, TaskNodeIndex)>::new(); + let mut nearest_topological_tasks = Vec::::new(); for task_index in me.task_graph.node_indices() { let task_id = &me.task_graph[task_index].task_id; - let task_name = &task_id.task_name; + let task_name = task_id.task_name.as_str(); let package_index = task_id.package_index; - // For every task, - // DFS starting from the package where it's defined. - depth_first_search(&me.package_graph, Some(package_index), |event| { + + // For every task, find nearest tasks with the same name. + // If there is a task with the same name in a deeper dependency, it will be connected via that nearer dependency's task. + me.find_nearest_topological_tasks( + task_name, + package_index, + &mut nearest_topological_tasks, + ); + for nearest_task_index in nearest_topological_tasks.drain(..) { + if let Some(existing_edge_index) = + me.task_graph.find_edge(task_index, nearest_task_index) + { + // If an edge already exists, + let existing_edge = &mut me.task_graph[existing_edge_index]; + match existing_edge.0 { + TaskDependencyTypeInner::Explicit => { + // upgrade from Explicit to Both + existing_edge.0 = TaskDependencyTypeInner::Both; + } + TaskDependencyTypeInner::Topological | TaskDependencyTypeInner::Both => { + // already has topological dependency, do nothing + } + } + } else { + // add new topological edge if not exists + me.task_graph.add_edge( + task_index, + nearest_task_index, + TaskDependencyType(TaskDependencyTypeInner::Topological), + ); + } + } + } + Ok(me) + } + + /// Find the nearest tasks with the given name starting from the given package node index. + /// This method only considers the package graph topology. It doesn't rely on existing topological edges of + /// the task graph, because the starting package itself may not contain a task with the given name + /// + /// The task with the given name in the starting package won't be included in the result even if there's one. + /// + /// This performs a BFS on the package graph starting from `starting_from`, + /// and collects the first found tasks with the given name in each branch. + /// + /// For example, if the package graph is A -> B -> C and A -> D -> C, + /// and we are looking for task "build" starting from A, + /// + /// - No matter A contains "build" or not, it's not included in the result. + /// - If B and D both have a "build" task, both will be returned, but C's "build" task will not be returned + /// because it's not the nearest in either branch. + /// - If B or D doesn't have a "build" task, then C's "build" task will be returned. + fn find_nearest_topological_tasks( + &self, + task_name: &str, + starting_from: PackageNodeIndex, + out: &mut Vec, + ) { + // DFS the package graph starting from `starting_from`, + depth_first_search( + self.indexed_package_graph.package_graph(), + Some(starting_from), + |event| { let DfsEvent::TreeEdge(_, dependency_package_index) = event else { return Control::<()>::Continue; }; - // If a direct or indirect dependency has a task with the same name - if let Some(dependency_task_index) = me.node_indices_by_task_id.get(&TaskId { + + if let Some(dependency_task_index) = self.node_indices_by_task_id.get(&TaskId { package_index: dependency_package_index, - task_name: task_name.clone(), + task_name: task_name.into(), }) { - // form an edge from the current task to that task - topological_edges.push((task_index, *dependency_task_index)); + // Encountered a package containing the task with the same name + // collect the task index + out.push(*dependency_task_index); + // And stop searching further down this branch - // If there is a task with the same name in a deeper dependency, it will be connected via that nearer dependency's task. Control::Prune } else { Control::Continue } - }); - } - for (from_node_index, to_node_index) in topological_edges { - if let Some(existing_edge_index) = - me.task_graph.find_edge(from_node_index, to_node_index) - { - let existing_edge = &mut me.task_graph[existing_edge_index]; - match existing_edge.0 { - TaskDependencyTypeInner::Explicit => { - // upgrade to Both - existing_edge.0 = TaskDependencyTypeInner::Both; - } - TaskDependencyTypeInner::Topological | TaskDependencyTypeInner::Both => { - // already has topological dependency, do nothing - } - } - } else { - // add new topological edge if not exists - me.task_graph.add_edge( - from_node_index, - to_node_index, - TaskDependencyType(TaskDependencyTypeInner::Topological), - ); - } - } - Ok(me) + }, + ); } - /// Lookup the node index of a task by its specifier. + /// Lookup the node index of a task by a specifier. /// /// The specifier can be either 'packageName#taskName' or just 'taskName' (in which case the task in the origin package is looked up). - fn get_task_index_by_specifier( + fn get_task_index_by_specifier( &self, - specifier: &str, - package_origin: PackageNodeIndex, - ) -> Result { - let (package_index, task_name): (PackageNodeIndex, Str) = - if let Some((package_name, task_name)) = specifier.rsplit_once('#') { - // Lookup package path by the package name from '#' - let Some(package_indices) = self.package_indices_by_name.get(package_name) else { - return Err(SpecifierLookupError::PackageNameNotFound { - package_name: package_name.into(), - }); - }; - if package_indices.len() > 1 { - return Err(SpecifierLookupError::AmbiguousPackageName { - package_name: package_name.into(), - package_paths: package_indices - .iter() - .map(|package_index| self.package_graph[*package_index].path.clone()) - .collect(), - }); - }; - (*package_indices.first(), task_name.into()) - } else { - // No '#', so the specifier only contains task name, look up in the origin path package - (package_origin, specifier.into()) + specifier: TaskSpecifier, + get_package_origin: impl FnOnce() -> Result, + ) -> Result> { + let package_index = if let Some(package_name) = specifier.package_name { + // Lookup package path by the package name from '#' + let Some(package_indices) = + self.indexed_package_graph.get_package_indices_by_name(&package_name) + else { + return Err(SpecifierLookupError::PackageNameNotFound { + package_name: package_name.into(), + }); + }; + if package_indices.len() > 1 { + return Err(SpecifierLookupError::AmbiguousPackageName { + package_name: package_name.into(), + package_paths: package_indices + .iter() + .map(|package_index| { + Arc::clone( + &self.indexed_package_graph.package_graph()[*package_index] + .absolute_path, + ) + }) + .collect(), + }); }; - let task_id = TaskId { task_name, package_index }; - let Some(node_index) = self.node_indices_by_task_id.get(&task_id) else { + *package_indices.first() + } else { + // No '#', so the specifier only contains task name, look up in the origin path package + get_package_origin().map_err(|err| SpecifierLookupError::PackageUnknown { + unspecifier_package_error: err, + task_name: specifier.task_name.clone(), + })? + }; + let task_id_to_lookup = TaskId { task_name: specifier.task_name, package_index }; + let Some(node_index) = self.node_indices_by_task_id.get(&task_id_to_lookup) else { return Err(SpecifierLookupError::TaskNameNotFound { - package_name: self.package_graph[package_index].package_json.name.clone(), - task_name: task_id.task_name.clone(), + package_name: self.indexed_package_graph.package_graph()[package_index] + .package_json + .name + .clone(), + task_name: task_id_to_lookup.task_name.clone(), + package_index, }); }; Ok(*node_index) } - /// Create a stable json representation of the task graph for snapshot testing. - /// - /// All paths are relative to `base_dir`. - pub fn snapshot(&self, base_dir: &AbsolutePath) -> serde_json::Value { - use vite_path::RelativePathBuf; - - #[derive(serde::Serialize, PartialEq, PartialOrd, Eq, Ord)] - struct TaskIdSnapshot { - package_dir: RelativePathBuf, - task_name: Str, - } - impl TaskIdSnapshot { - fn from_task_id( - task_id: &TaskId, - package_graph: &DiGraph, - ) -> Self { - Self { - task_name: task_id.task_name.clone(), - package_dir: package_graph[task_id.package_index].path.clone(), - } - } - } - - #[derive(serde::Serialize)] - struct TaskNodeSnapshot { - id: TaskIdSnapshot, - command: Str, - cwd: RelativePathBuf, - depends_on: Vec<(TaskIdSnapshot, TaskDependencyType)>, - } + pub fn task_graph(&self) -> &DiGraph { + &self.task_graph + } - let mut node_snapshots = - Vec::::with_capacity(self.task_graph.node_count()); - for a in self.task_graph.node_indices() { - let node = &self.task_graph[a]; - let mut depends_on: Vec<(TaskIdSnapshot, TaskDependencyType)> = self - .task_graph - .edges_directed(a, petgraph::Direction::Outgoing) - .map(|edge| { - use petgraph::visit::EdgeRef as _; - let target_node = &self.task_graph[edge.target()]; - ( - TaskIdSnapshot::from_task_id(&target_node.task_id, &self.package_graph), - *edge.weight(), - ) - }) - .collect(); - depends_on.sort_unstable_by(|a, b| a.0.cmp(&b.0)); - node_snapshots.push(TaskNodeSnapshot { - id: TaskIdSnapshot::from_task_id(&node.task_id, &self.package_graph), - command: node.resolved_config.command.clone(), - cwd: node.resolved_config.cwd.strip_prefix(base_dir).unwrap().unwrap(), - depends_on, - }); - } - node_snapshots.sort_unstable_by(|a, b| a.id.cmp(&b.id)); + pub fn get_package_name(&self, package_index: PackageNodeIndex) -> &str { + self.indexed_package_graph.package_graph()[package_index].package_json.name.as_str() + } - serde_json::to_value(&node_snapshots).unwrap() + pub fn get_package_path(&self, package_index: PackageNodeIndex) -> &Arc { + &self.indexed_package_graph.package_graph()[package_index].absolute_path } } diff --git a/crates/vite_task_graph/src/package_graph.rs b/crates/vite_task_graph/src/package_graph.rs new file mode 100644 index 00000000..1c41dfe3 --- /dev/null +++ b/crates/vite_task_graph/src/package_graph.rs @@ -0,0 +1,75 @@ +use std::{ + collections::{HashMap, hash_map::Entry}, + sync::Arc, +}; + +use petgraph::graph::DiGraph; +use vec1::smallvec_v1::SmallVec1; +use vite_path::AbsolutePath; +use vite_str::Str; +use vite_workspace::{DependencyType, PackageInfo, PackageIx, PackageNodeIndex}; + +/// Package graph with additional HashMaps for quick task lookup +pub struct IndexedPackageGraph { + package_graph: DiGraph, + + /// Grouping package indices by their package names. + /// Due to rare but possible name conflicts in monorepos, we use `SmallVec1` to store multiple dirs for same name. + package_indices_by_name: HashMap>, + + /// package indices by their absolute paths for quick lookup based on cwd + package_indices_by_paths: HashMap, PackageNodeIndex>, +} + +impl IndexedPackageGraph { + pub fn index(package_graph: DiGraph) -> Self { + // Index package indices by their absolute paths for quick lookup based on cwd + let package_indices_by_paths = package_graph + .node_indices() + .map(|package_index| { + let absolute_path: Arc = + Arc::clone(&package_graph[package_index].absolute_path); + (absolute_path, package_index) + }) + .collect::, PackageNodeIndex>>(); + + // Grouping package indices by their package names. + let mut package_indices_by_name: HashMap> = + HashMap::new(); + for package_index in package_graph.node_indices() { + let package = &package_graph[package_index]; + match package_indices_by_name.entry(package.package_json.name.clone()) { + Entry::Vacant(vacant) => { + vacant.insert(SmallVec1::new(package_index)); + } + Entry::Occupied(occupied) => { + occupied.into_mut().push(package_index); + } + } + } + Self { package_graph, package_indices_by_name, package_indices_by_paths } + } + + /// Get package index from a given current working directory by traversing up the directory tree. + pub fn get_package_index_from_cwd(&self, cwd: &AbsolutePath) -> Option { + let mut cur_path = cwd; + loop { + if let Some(package_index) = self.package_indices_by_paths.get(cur_path) { + return Some(*package_index); + } + cur_path = cur_path.parent()?; + } + } + + /// Get package indices by package name. + pub fn get_package_indices_by_name( + &self, + package_name: &Str, + ) -> Option<&SmallVec1<[PackageNodeIndex; 1]>> { + self.package_indices_by_name.get(package_name) + } + + pub fn package_graph(&self) -> &DiGraph { + &self.package_graph + } +} diff --git a/crates/vite_task_graph/src/query/cli.rs b/crates/vite_task_graph/src/query/cli.rs new file mode 100644 index 00000000..cb5e6e41 --- /dev/null +++ b/crates/vite_task_graph/src/query/cli.rs @@ -0,0 +1,71 @@ +use std::{collections::HashSet, sync::Arc}; + +use vite_path::AbsolutePath; +use vite_str::Str; + +use super::TaskQueryKind; +use crate::{query::TaskQuery, specifier::TaskSpecifier}; + +/// Represents task query args of `vite run` +/// It will be converted to `TaskQuery`, but may be invalid (contains conflicting options), +/// if so the error is returned early before loading the task graph. +#[derive(Debug, clap::Parser)] +pub struct CLITaskQuery { + /// Specifies one or multiple tasks to run, in form of `packageName#taskName` or `taskName`. + tasks: Vec, + + /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. + #[clap(default_value = "false", short, long)] + recursive: bool, + + /// Run tasks found in the current package and all its transitive dependencies, in topological order based on package dependencies. + #[clap(default_value = "false", short, long)] + transitive: bool, + + /// Do not run dependencies specified in `dependsOn` fields. + #[clap(default_value = "false", long)] + ignore_depends_on: bool, +} + +#[derive(thiserror::Error, Debug)] +pub enum CLITaskQueryError { + #[error("--recursive and --transitive cannot be used together")] + RecursiveTransitiveConflict, + + #[error("cannot specify package '{package_name}' for task '{task_name}' with --recursive")] + PackageNameSpecifiedWithRecursive { package_name: Str, task_name: Str }, +} + +impl CLITaskQuery { + /// Convert to `TaskQuery`, or return an error if invalid. + pub fn into_task_query(self, cwd: &Arc) -> Result { + let include_explicit_deps = !self.ignore_depends_on; + + let kind = if self.recursive { + if self.transitive { + return Err(CLITaskQueryError::RecursiveTransitiveConflict); + } + let task_names: HashSet = self + .tasks + .into_iter() + .map(|s| { + if let Some(package_name) = s.package_name { + return Err(CLITaskQueryError::PackageNameSpecifiedWithRecursive { + package_name, + task_name: s.task_name, + }); + } + Ok(s.task_name) + }) + .collect::>()?; + TaskQueryKind::Recursive { task_names } + } else { + TaskQueryKind::Normal { + task_specifiers: self.tasks.into_iter().collect(), + cwd: Arc::clone(cwd), + include_topological_deps: self.transitive, + } + }; + Ok(TaskQuery { kind, include_explicit_deps }) + } +} diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs new file mode 100644 index 00000000..4d3e08c1 --- /dev/null +++ b/crates/vite_task_graph/src/query/mod.rs @@ -0,0 +1,174 @@ +pub mod cli; + +use std::{collections::HashSet, sync::Arc}; + +use petgraph::{prelude::DiGraphMap, visit::EdgeRef}; +use vite_path::AbsolutePath; +use vite_str::Str; + +use crate::{ + IndexedTaskGraph, SpecifierLookupError, TaskDependencyType, TaskNodeIndex, + specifier::TaskSpecifier, +}; + +/// Different kinds of task queries. +#[derive(Debug)] +pub enum TaskQueryKind { + /// A normal task query specifying task specifiers and options. + /// The tasks will be searched in packages in task specifiers, or located from cwd. + Normal { + task_specifiers: HashSet, + /// Where the query is being run from. + cwd: Arc, + /// Whether to include topological dependencies + include_topological_deps: bool, + }, + /// A recursive task query specifying one or multiple task names. + /// It will match all tasks with the given names across all packages with topological ordering. + /// The whole workspace will be searched, so cwd is not relevant. + Recursive { task_names: HashSet }, +} + +/// Represents a valid query for a task and its dependencies, usually issued from a CLI command `vite run ...`. +/// A query represented by this struct is always valid, but still may result in no tasks found. +#[derive(Debug)] +pub struct TaskQuery { + /// The kind of task query + pub kind: TaskQueryKind, + /// Whether to include explicit dependencies + pub include_explicit_deps: bool, +} + +/// A task execution graph queried from a `TaskQuery`. +/// +/// The nodes are task indices for `TaskGraph`. +/// The edges represent the final dependency relationships between tasks. No edge weights. +pub type TaskExecutionGraph = DiGraphMap; + +#[derive(Debug, thiserror::Error)] +#[error("The current working directory {cwd:?} is in not any package")] +pub struct PackageUnknownError { + pub cwd: Arc, +} + +impl IndexedTaskGraph { + pub fn query_tasks( + &self, + query: TaskQuery, + ) -> Result> { + let mut execution_graph = TaskExecutionGraph::default(); + + let include_topologicial_deps = match &query.kind { + TaskQueryKind::Normal { include_topological_deps, .. } => *include_topological_deps, + TaskQueryKind::Recursive { .. } => true, // recursive means topological across all packages + }; + + // Add starting tasks without dependencies + match query.kind { + TaskQueryKind::Normal { task_specifiers, cwd, include_topological_deps } => { + let package_index_from_cwd = + self.indexed_package_graph.get_package_index_from_cwd(&cwd); + + let mut nearest_topological_tasks = Vec::::new(); + + // For every task specifier, add matching tasks + for specifier in task_specifiers { + // Find the starting task + let starting_task_result = + self.get_task_index_by_specifier(specifier.clone(), || { + package_index_from_cwd + .ok_or_else(|| PackageUnknownError { cwd: Arc::clone(&cwd) }) + }); + + match starting_task_result { + Ok(starting_task) => { + // Found it, add to execution graph + execution_graph.add_node(starting_task); + } + // Task not found, but package located, and the query requests topological deps + // This happens when running `vite run --transitive taskName` in a package without `taskName`, but its dependencies have it. + Err(err @ SpecifierLookupError::TaskNameNotFound { package_index, .. }) + if include_topological_deps => + { + // try to find nearest task + self.find_nearest_topological_tasks( + &specifier.task_name, + package_index, + &mut nearest_topological_tasks, + ); + if nearest_topological_tasks.is_empty() { + // No nearest task found, return original error + return Err(err); + } + // Add nearest tasks to execution graph + // Topological dependencies of nearest tasks will be added later + for nearest_task in nearest_topological_tasks.drain(..) { + execution_graph.add_node(nearest_task); + } + } + Err(err) => { + // Not recoverable by finding nearest package, return error + return Err(err); + } + } + } + } + TaskQueryKind::Recursive { task_names } => { + // Add all tasks matching the names across all packages + for task_index in self.task_graph.node_indices() { + let current_task_name = self.task_graph[task_index].task_id.task_name.as_str(); + if task_names.contains(current_task_name) { + execution_graph.add_node(task_index); + } + } + } + } + + // Add dependencies as requested + // The order matters: add topological dependencies first, then explicit dependencies. + // We don't want to include topological dependencies of explicit dependencies even both types are requested. + if include_topologicial_deps { + self.add_dependencies(&mut execution_graph, TaskDependencyType::is_topological); + } + if query.include_explicit_deps { + self.add_dependencies(&mut execution_graph, TaskDependencyType::is_explicit); + } + + Ok(execution_graph) + } + + /// Recursively add dependencies to the execution graph based on filtered edges in the task graph + fn add_dependencies( + &self, + execution_graph: &mut TaskExecutionGraph, + mut filter_edge: impl FnMut(TaskDependencyType) -> bool, + ) { + let mut current_starting_node_indices: HashSet = + execution_graph.nodes().collect(); + + // Continue until no new nodes are added + while !current_starting_node_indices.is_empty() { + // Record newly added nodes in this iteration as starting nodes for next iteration + let mut next_starting_node_indices = HashSet::::new(); + + for from_node in current_starting_node_indices { + // For each starting node, traverse its outgoing edges + for edge_ref in self.task_graph.edges(from_node) { + let to_node = edge_ref.target(); + let dependency_type = edge_ref.weight(); + if filter_edge(*dependency_type) { + let is_to_node_new = !execution_graph.contains_node(to_node); + // Add the dependency edge + execution_graph.add_edge(from_node, to_node, ()); + + // Add to_node for next iteration if it's newly added. + if is_to_node_new { + next_starting_node_indices.insert(to_node); + } + } + } + } + current_starting_node_indices = next_starting_node_indices; + } + } +} diff --git a/crates/vite_task_graph/src/specifier.rs b/crates/vite_task_graph/src/specifier.rs new file mode 100644 index 00000000..3d25de32 --- /dev/null +++ b/crates/vite_task_graph/src/specifier.rs @@ -0,0 +1,34 @@ +use std::{convert::Infallible, str::FromStr}; + +use vite_str::Str; + +/// Parsed task specifier (`"packageName#taskName"` or `"taskName"`) +/// +/// For `taskName`, `package_name` will be `None`. +/// For `#taskName`, `package_name` will be `Some("")`. It's valid to have an empty package name. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TaskSpecifier { + pub package_name: Option, + pub task_name: Str, +} + +impl TaskSpecifier { + pub fn parse_raw(raw_specifier: &str) -> Self { + if let Some((package_name, task_name)) = raw_specifier.rsplit_once('#') { + TaskSpecifier { + package_name: Some(Str::from(package_name)), + task_name: Str::from(task_name), + } + } else { + TaskSpecifier { package_name: None, task_name: Str::from(raw_specifier) } + } + } +} + +impl FromStr for TaskSpecifier { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(TaskSpecifier::parse_raw(s)) + } +} diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/cli-queries.toml b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/cli-queries.toml new file mode 100644 index 00000000..41491249 --- /dev/null +++ b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/cli-queries.toml @@ -0,0 +1,54 @@ +[[query]] +name = "simple task by name" +cwd = "packages/a" +args = ["build"] + +[[query]] +name = "under subfolder of package" +cwd = "packages/a/src" +args = ["build"] + +[[query]] +name = "explicit package name under different package" +cwd = "packages/a" +args = ["@test/c#build"] + +[[query]] +name = "explicit package name under non-package cwd" +cwd = "" +args = ["@test/c#build"] + +[[query]] +name = "ambiguous task name" +cwd = "" +args = ["@test/a#build"] + +[[query]] +name = "ignore depends on" +cwd = "packages/a" +args = ["--ignore-depends-on", "build"] + +[[query]] +name = "transitive" +cwd = "packages/a" +args = ["--transitive", "build"] + +[[query]] +name = "transitive in package without the task" +cwd = "packages/a" +args = ["--transitive", "lint"] + +[[query]] +name = "transitive non existent task" +cwd = "packages/a" +args = ["--transitive", "non-existent-task"] + +[[query]] +name = "recursive" +cwd = "" +args = ["--recursive", "build"] + +[[query]] +name = "recursive and transitive" +cwd = "" +args = ["--recursive", "--transitive", "build"] diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/package.json b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/package.json deleted file mode 100644 index e976fa5d..00000000 --- a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "transitive-dependency-workspace", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ] -} diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/package.json b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/package.json index 22dc7f47..5d4fadc3 100644 --- a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/package.json +++ b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/package.json @@ -5,6 +5,7 @@ "build": "echo Building A" }, "dependencies": { - "@test/b": "workspace:*" + "@test/b1": "workspace:*", + "@test/b2": "workspace:*" } } diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/src/.gitkeep b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/src/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/vite.config.json b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/vite.config.json new file mode 100644 index 00000000..7f96f0dd --- /dev/null +++ b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/a/vite.config.json @@ -0,0 +1,10 @@ +{ + "tasks": { + "build": { + "dependsOn": ["test"] + }, + "test": { + "command": "echo test a" + } + } +} diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/another-a/package.json b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/another-a/package.json new file mode 100644 index 00000000..5841d16a --- /dev/null +++ b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/another-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/a", + "version": "1.0.0", + "scripts": { + "build": "echo Building another A" + } +} diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b/package.json b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b1/package.json similarity index 53% rename from crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b/package.json rename to crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b1/package.json index c464a0f5..d034f217 100644 --- a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b/package.json +++ b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b1/package.json @@ -1,6 +1,9 @@ { - "name": "@test/b", + "name": "@test/b1", "version": "1.0.0", + "scripts": { + "lint": "echo lint b1" + }, "dependencies": { "@test/c": "workspace:*" } diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b2/package.json b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b2/package.json new file mode 100644 index 00000000..88b142ad --- /dev/null +++ b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/b2/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/b2", + "version": "1.0.0", + "scripts": { + "build": "echo build b2" + }, + "dependencies": { + "@test/c": "workspace:*" + } +} diff --git a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/c/package.json b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/c/package.json index 6e9b4cfd..55776d2b 100644 --- a/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/c/package.json +++ b/crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace/packages/c/package.json @@ -2,6 +2,7 @@ "name": "@test/c", "version": "1.0.0", "scripts": { + "lint": "echo lint c", "build": "echo Building C" } } diff --git a/crates/vite_task_graph/tests/snapshots.rs b/crates/vite_task_graph/tests/snapshots.rs index 48e0c037..877de517 100644 --- a/crates/vite_task_graph/tests/snapshots.rs +++ b/crates/vite_task_graph/tests/snapshots.rs @@ -1,11 +1,153 @@ -use std::path::Path; +use core::panic; +use std::{path::Path, sync::Arc}; +use clap::Parser; use copy_dir::copy_dir; +use petgraph::visit::EdgeRef as _; use tokio::runtime::Runtime; -use vite_path::AbsolutePath; -use vite_task_graph::loader::JsonUserConfigLoader; +use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_str::Str; +use vite_task_graph::{ + IndexedTaskGraph, SpecifierLookupError, TaskDependencyType, TaskNodeIndex, + loader::JsonUserConfigLoader, + query::{PackageUnknownError, TaskExecutionGraph, cli::CLITaskQuery}, +}; use vite_workspace::find_workspace_root; +#[derive(serde::Serialize, PartialEq, PartialOrd, Eq, Ord)] +struct TaskIdSnapshot { + package_dir: RelativePathBuf, + task_name: Str, +} +impl TaskIdSnapshot { + fn new( + task_index: TaskNodeIndex, + base_dir: &AbsolutePath, + indexed_task_graph: &IndexedTaskGraph, + ) -> Self { + let task_id = &indexed_task_graph.task_graph()[task_index].task_id; + Self { + task_name: task_id.task_name.clone(), + package_dir: indexed_task_graph + .get_package_path(task_id.package_index) + .strip_prefix(base_dir) + .unwrap() + .unwrap(), + } + } +} + +/// Create a stable json representation of the task graph for snapshot testing. +/// +/// All paths are relative to `base_dir`. +fn snapshot_task_graph( + indexed_task_graph: &IndexedTaskGraph, + base_dir: &AbsolutePath, +) -> impl serde::Serialize { + #[derive(serde::Serialize)] + struct TaskNodeSnapshot { + id: TaskIdSnapshot, + command: Str, + cwd: RelativePathBuf, + depends_on: Vec<(TaskIdSnapshot, TaskDependencyType)>, + } + + let task_graph = indexed_task_graph.task_graph(); + let mut node_snapshots = Vec::::with_capacity(task_graph.node_count()); + for task_index in task_graph.node_indices() { + let task_node = &task_graph[task_index]; + let mut depends_on: Vec<(TaskIdSnapshot, TaskDependencyType)> = task_graph + .edges_directed(task_index, petgraph::Direction::Outgoing) + .map(|edge| { + (TaskIdSnapshot::new(edge.target(), base_dir, indexed_task_graph), *edge.weight()) + }) + .collect(); + depends_on.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + node_snapshots.push(TaskNodeSnapshot { + id: TaskIdSnapshot::new(task_index, base_dir, indexed_task_graph), + command: task_node.resolved_config.command.clone(), + cwd: task_node.resolved_config.cwd.strip_prefix(base_dir).unwrap().unwrap(), + depends_on, + }); + } + node_snapshots.sort_unstable_by(|a, b| a.id.cmp(&b.id)); + + node_snapshots +} + +/// Create a stable json representation of the task graph for snapshot testing. +/// +/// All paths are relative to `base_dir`. +fn snapshot_execution_graph( + execution_graph: &TaskExecutionGraph, + indexed_task_graph: &IndexedTaskGraph, + base_dir: &AbsolutePath, +) -> impl serde::Serialize { + #[derive(serde::Serialize, PartialEq)] + struct ExecutionNodeSnapshot { + task: TaskIdSnapshot, + deps: Vec, + } + + let mut execution_node_snapshots = Vec::::new(); + for task_index in execution_graph.nodes() { + let mut deps = execution_graph + .neighbors(task_index) + .map(|dep_index| TaskIdSnapshot::new(dep_index, base_dir, indexed_task_graph)) + .collect::>(); + deps.sort_unstable(); + + execution_node_snapshots.push(ExecutionNodeSnapshot { + task: TaskIdSnapshot::new(task_index, base_dir, indexed_task_graph), + deps, + }); + } + execution_node_snapshots.sort_unstable_by(|a, b| a.task.cmp(&b.task)); + execution_node_snapshots +} + +/// Modify absolute paths to be stable across different tmpdir locations. +fn stabilize_absolute_path(path: &mut Arc, base_dir: &AbsolutePath) { + let relative_path = path.strip_prefix(base_dir).unwrap().unwrap(); + // this path is considered absolute on all platforms + let new_base_dir = AbsolutePath::new("//?/workspace/").unwrap(); + *path = new_base_dir.join(relative_path).into(); +} + +/// Modify absolute paths in the SpecifierLookupError to be stable across different tmpdir locations. +fn stabilize_specifier_lookup_error( + err: &mut SpecifierLookupError, + base_dir: &AbsolutePath, +) { + match err { + SpecifierLookupError::AmbiguousPackageName { package_paths, .. } => { + for path in package_paths.iter_mut() { + stabilize_absolute_path(path, base_dir); + } + } + SpecifierLookupError::PackageNameNotFound { .. } => {} + SpecifierLookupError::TaskNameNotFound { package_index, .. } => { + *package_index = Default::default() + } + SpecifierLookupError::PackageUnknown { unspecifier_package_error, .. } => { + stabilize_absolute_path(&mut unspecifier_package_error.cwd, base_dir); + } + } +} + +#[derive(serde::Deserialize)] +struct CLIQuery { + pub name: Str, + pub args: Vec, + pub cwd: RelativePathBuf, +} + +#[derive(serde::Deserialize, Default)] +struct CLIQueriesFile { + #[serde(rename = "query")] // toml usually uses singular for arrays + pub queries: Vec, +} + fn run_case(runtime: &Runtime, tmpdir: &AbsolutePath, case_path: &Path) { let case_name = case_path.file_name().unwrap().to_str().unwrap(); if case_name.starts_with(".") { @@ -24,13 +166,58 @@ fn run_case(runtime: &Runtime, tmpdir: &AbsolutePath, case_path: &Path) { case_name ); + let cli_queries_toml_path = case_path.join("cli-queries.toml"); + let cli_queries_file: CLIQueriesFile = match std::fs::read(&cli_queries_toml_path) { + Ok(content) => toml::from_slice(&content).unwrap(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Default::default(), + Err(err) => panic!("Failed to read cli-queries.toml for case {}: {}", case_name, err), + }; + runtime.block_on(async { - let task_graph = - vite_task_graph::TaskGraph::load(workspace_root, JsonUserConfigLoader::default()) - .await - .expect(&format!("Failed to load task graph for case {case_name}")); - let task_graph_snapshot = task_graph.snapshot(&case_stage_path); + let indexed_task_graph = vite_task_graph::IndexedTaskGraph::load( + workspace_root, + JsonUserConfigLoader::default(), + ) + .await + .expect(&format!("Failed to load task graph for case {case_name}")); + + let task_graph_snapshot = snapshot_task_graph(&indexed_task_graph, &case_stage_path); insta::assert_json_snapshot!("task graph", task_graph_snapshot); + + for cli_query in cli_queries_file.queries { + let snapshot_name = format!("query - {}", cli_query.name); + + let cli_task_query = CLITaskQuery::try_parse_from( + std::iter::once("vite-run") // dummy program name + .chain(cli_query.args.iter().map(|s| s.as_str())), + ) + .expect(&format!( + "Failed to parse CLI args for query '{}' in case '{}'", + cli_query.name, case_name + )); + + let cwd: Arc = case_stage_path.join(&cli_query.cwd).into(); + let task_query = match cli_task_query.into_task_query(&cwd) { + Ok(ok) => ok, + Err(err) => { + insta::assert_debug_snapshot!(snapshot_name, err); + continue; + } + }; + + let execution_graph = match indexed_task_graph.query_tasks(task_query) { + Ok(ok) => ok, + Err(mut err) => { + stabilize_specifier_lookup_error(&mut err, &case_stage_path); + insta::assert_debug_snapshot!(snapshot_name, err); + continue; + } + }; + + let execution_graph_snapshot = + snapshot_execution_graph(&execution_graph, &indexed_task_graph, &case_stage_path); + insta::assert_json_snapshot!(snapshot_name, execution_graph_snapshot); + } }); } diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - ambiguous task name@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - ambiguous task name@transitive-dependency-workspace.snap new file mode 100644 index 00000000..3b2a19c2 --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - ambiguous task name@transitive-dependency-workspace.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: err +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +AmbiguousPackageName { + package_name: "@test/a", + package_paths: [ + AbsolutePath( + "//?/workspace/packages/a", + ), + AbsolutePath( + "//?/workspace/packages/another-a", + ), + ], +} diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - explicit package name under different package@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - explicit package name under different package@transitive-dependency-workspace.snap new file mode 100644 index 00000000..f5e5cacc --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - explicit package name under different package@transitive-dependency-workspace.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: execution_graph_snapshot +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +[ + { + "task": { + "package_dir": "packages/c", + "task_name": "build" + }, + "deps": [] + } +] diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - explicit package name under non-package cwd@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - explicit package name under non-package cwd@transitive-dependency-workspace.snap new file mode 100644 index 00000000..f5e5cacc --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - explicit package name under non-package cwd@transitive-dependency-workspace.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: execution_graph_snapshot +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +[ + { + "task": { + "package_dir": "packages/c", + "task_name": "build" + }, + "deps": [] + } +] diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - ignore depends on@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - ignore depends on@transitive-dependency-workspace.snap new file mode 100644 index 00000000..32511355 --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - ignore depends on@transitive-dependency-workspace.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: execution_graph_snapshot +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +[ + { + "task": { + "package_dir": "packages/a", + "task_name": "build" + }, + "deps": [] + } +] diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - recursive and transitive@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - recursive and transitive@transitive-dependency-workspace.snap new file mode 100644 index 00000000..662f385a --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - recursive and transitive@transitive-dependency-workspace.snap @@ -0,0 +1,6 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: err +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +RecursiveTransitiveConflict diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - recursive@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - recursive@transitive-dependency-workspace.snap new file mode 100644 index 00000000..b438fcb0 --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - recursive@transitive-dependency-workspace.snap @@ -0,0 +1,60 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: execution_graph_snapshot +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +[ + { + "task": { + "package_dir": "packages/a", + "task_name": "build" + }, + "deps": [ + { + "package_dir": "packages/a", + "task_name": "test" + }, + { + "package_dir": "packages/b2", + "task_name": "build" + }, + { + "package_dir": "packages/c", + "task_name": "build" + } + ] + }, + { + "task": { + "package_dir": "packages/a", + "task_name": "test" + }, + "deps": [] + }, + { + "task": { + "package_dir": "packages/another-a", + "task_name": "build" + }, + "deps": [] + }, + { + "task": { + "package_dir": "packages/b2", + "task_name": "build" + }, + "deps": [ + { + "package_dir": "packages/c", + "task_name": "build" + } + ] + }, + { + "task": { + "package_dir": "packages/c", + "task_name": "build" + }, + "deps": [] + } +] diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - simple task by name@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - simple task by name@transitive-dependency-workspace.snap new file mode 100644 index 00000000..71d68865 --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - simple task by name@transitive-dependency-workspace.snap @@ -0,0 +1,26 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: execution_graph_snapshot +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +[ + { + "task": { + "package_dir": "packages/a", + "task_name": "build" + }, + "deps": [ + { + "package_dir": "packages/a", + "task_name": "test" + } + ] + }, + { + "task": { + "package_dir": "packages/a", + "task_name": "test" + }, + "deps": [] + } +] diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive in package without the task@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive in package without the task@transitive-dependency-workspace.snap new file mode 100644 index 00000000..e01dc8dd --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive in package without the task@transitive-dependency-workspace.snap @@ -0,0 +1,26 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: execution_graph_snapshot +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +[ + { + "task": { + "package_dir": "packages/b1", + "task_name": "lint" + }, + "deps": [ + { + "package_dir": "packages/c", + "task_name": "lint" + } + ] + }, + { + "task": { + "package_dir": "packages/c", + "task_name": "lint" + }, + "deps": [] + } +] diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive non existent task@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive non existent task@transitive-dependency-workspace.snap new file mode 100644 index 00000000..9e30bc13 --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive non existent task@transitive-dependency-workspace.snap @@ -0,0 +1,10 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: err +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +TaskNameNotFound { + package_name: "@test/a", + task_name: "non-existent-task", + package_index: NodeIndex(PackageIx(0)), +} diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive@transitive-dependency-workspace.snap new file mode 100644 index 00000000..5036dfa5 --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - transitive@transitive-dependency-workspace.snap @@ -0,0 +1,53 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: execution_graph_snapshot +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +[ + { + "task": { + "package_dir": "packages/a", + "task_name": "build" + }, + "deps": [ + { + "package_dir": "packages/a", + "task_name": "test" + }, + { + "package_dir": "packages/b2", + "task_name": "build" + }, + { + "package_dir": "packages/c", + "task_name": "build" + } + ] + }, + { + "task": { + "package_dir": "packages/a", + "task_name": "test" + }, + "deps": [] + }, + { + "task": { + "package_dir": "packages/b2", + "task_name": "build" + }, + "deps": [ + { + "package_dir": "packages/c", + "task_name": "build" + } + ] + }, + { + "task": { + "package_dir": "packages/c", + "task_name": "build" + }, + "deps": [] + } +] diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__query - under subfolder of package@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__query - under subfolder of package@transitive-dependency-workspace.snap new file mode 100644 index 00000000..71d68865 --- /dev/null +++ b/crates/vite_task_graph/tests/snapshots/snapshots__query - under subfolder of package@transitive-dependency-workspace.snap @@ -0,0 +1,26 @@ +--- +source: crates/vite_task_graph/tests/snapshots.rs +expression: execution_graph_snapshot +input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspace +--- +[ + { + "task": { + "package_dir": "packages/a", + "task_name": "build" + }, + "deps": [ + { + "package_dir": "packages/a", + "task_name": "test" + } + ] + }, + { + "task": { + "package_dir": "packages/a", + "task_name": "test" + }, + "deps": [] + } +] diff --git a/crates/vite_task_graph/tests/snapshots/snapshots__task graph@transitive-dependency-workspace.snap b/crates/vite_task_graph/tests/snapshots/snapshots__task graph@transitive-dependency-workspace.snap index 24cf5297..62952fda 100644 --- a/crates/vite_task_graph/tests/snapshots/snapshots__task graph@transitive-dependency-workspace.snap +++ b/crates/vite_task_graph/tests/snapshots/snapshots__task graph@transitive-dependency-workspace.snap @@ -11,6 +11,72 @@ input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspac }, "command": "echo Building A", "cwd": "packages/a", + "depends_on": [ + [ + { + "package_dir": "packages/a", + "task_name": "test" + }, + "Explicit" + ], + [ + { + "package_dir": "packages/b2", + "task_name": "build" + }, + "Topological" + ], + [ + { + "package_dir": "packages/c", + "task_name": "build" + }, + "Topological" + ] + ] + }, + { + "id": { + "package_dir": "packages/a", + "task_name": "test" + }, + "command": "echo test a", + "cwd": "packages/a", + "depends_on": [] + }, + { + "id": { + "package_dir": "packages/another-a", + "task_name": "build" + }, + "command": "echo Building another A", + "cwd": "packages/another-a", + "depends_on": [] + }, + { + "id": { + "package_dir": "packages/b1", + "task_name": "lint" + }, + "command": "echo lint b1", + "cwd": "packages/b1", + "depends_on": [ + [ + { + "package_dir": "packages/c", + "task_name": "lint" + }, + "Topological" + ] + ] + }, + { + "id": { + "package_dir": "packages/b2", + "task_name": "build" + }, + "command": "echo build b2", + "cwd": "packages/b2", "depends_on": [ [ { @@ -29,5 +95,14 @@ input_file: crates/vite_task_graph/tests/fixtures/transitive-dependency-workspac "command": "echo Building C", "cwd": "packages/c", "depends_on": [] + }, + { + "id": { + "package_dir": "packages/c", + "task_name": "lint" + }, + "command": "echo lint c", + "cwd": "packages/c", + "depends_on": [] } ] diff --git a/crates/vite_workspace/src/lib.rs b/crates/vite_workspace/src/lib.rs index 36ebe03f..ecccb377 100644 --- a/crates/vite_workspace/src/lib.rs +++ b/crates/vite_workspace/src/lib.rs @@ -2,11 +2,11 @@ mod error; pub mod package; mod package_manager; -use std::{collections::hash_map::Entry, fs, io}; +use std::{collections::hash_map::Entry, fs, io, sync::Arc}; use petgraph::graph::{DefaultIx, DiGraph, EdgeIndex, IndexType, NodeIndex}; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use vec1::smallvec_v1::SmallVec1; use vite_glob::GlobPatternSet; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; @@ -96,10 +96,11 @@ impl WorkspaceMemberGlobs { } } -#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[derive(Debug, Clone)] pub struct PackageInfo { pub package_json: PackageJson, pub path: RelativePathBuf, + pub absolute_path: Arc, } #[derive(Default)] @@ -110,10 +111,19 @@ struct PackageGraphBuilder { } impl PackageGraphBuilder { - fn add_package(&mut self, package_path: RelativePathBuf, package_json: PackageJson) { + fn add_package( + &mut self, + package_path: RelativePathBuf, + absolute_path: Arc, + package_json: PackageJson, + ) { let deps = package_json.get_workspace_dependencies().collect::>(); let package_name = package_json.name.clone(); - let id = self.graph.add_node(PackageInfo { package_json, path: package_path.clone() }); + let id = self.graph.add_node(PackageInfo { + package_json, + path: package_path.clone(), + absolute_path, + }); // Always store by path self.id_and_deps_by_path.insert(package_path.clone(), (id, deps)); @@ -203,7 +213,11 @@ pub fn load_package_graph( WorkspaceFile::NonWorkspacePackage(file) => { // For non-workspace packages, add the package.json to the graph as a root package let package_json: PackageJson = serde_json::from_reader(file)?; - graph_builder.add_package(RelativePathBuf::default(), package_json); + graph_builder.add_package( + RelativePathBuf::default(), + workspace_root.path.into(), + package_json, + ); return graph_builder.build(); } @@ -213,8 +227,8 @@ pub fn load_package_graph( let mut has_root_package = false; for package_json_path in member_globs.get_package_json_paths(workspace_root.path)? { let package_json: PackageJson = serde_json::from_slice(&fs::read(&package_json_path)?)?; - let package_path = package_json_path.parent().unwrap(); - let Some(package_path) = package_path.strip_prefix(workspace_root.path)? else { + let absolute_path = package_json_path.parent().unwrap(); + let Some(package_path) = absolute_path.strip_prefix(workspace_root.path)? else { return Err(Error::PackageOutsideWorkspace { package_path: package_json_path, workspace_root: workspace_root.path.to_absolute_path_buf(), @@ -222,7 +236,7 @@ pub fn load_package_graph( }; has_root_package = has_root_package || package_path.as_str().is_empty(); - graph_builder.add_package(package_path, package_json); + graph_builder.add_package(package_path, absolute_path.into(), package_json); } // try add the root package anyway if the member globs do not include it. if !has_root_package { @@ -230,7 +244,11 @@ pub fn load_package_graph( match fs::read(&package_json_path) { Ok(package_json) => { let package_json: PackageJson = serde_json::from_slice(&package_json)?; - graph_builder.add_package(RelativePathBuf::default(), package_json); + graph_builder.add_package( + RelativePathBuf::default(), + workspace_root.path.into(), + package_json, + ); } Err(err) => { if err.kind() != io::ErrorKind::NotFound {