diff --git a/Cargo.lock b/Cargo.lock index 91e7815e..c4fb365b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1371,9 +1371,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hashlink" @@ -1410,12 +1410,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] @@ -3228,6 +3228,25 @@ dependencies = [ "vite_workspace", ] +[[package]] +name = "vite_task_plan" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures-core", + "futures-util", + "petgraph", + "sha2", + "supports-color", + "thiserror 2.0.17", + "tracing", + "vite_glob", + "vite_path", + "vite_shell", + "vite_str", + "vite_task_graph", +] + [[package]] name = "vite_tui" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 824c2988..f0b19e44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,6 +121,7 @@ vite_glob = { path = "crates/vite_glob" } vite_path = { path = "crates/vite_path" } vite_shell = { path = "crates/vite_shell" } vite_str = { path = "crates/vite_str" } +vite_task_graph = { path = "crates/vite_task_graph" } vite_workspace = { path = "crates/vite_workspace" } wax = "0.6.0" which = "8.0.0" diff --git a/crates/vite_str/src/lib.rs b/crates/vite_str/src/lib.rs index bf67aaa5..318c7b1d 100644 --- a/crates/vite_str/src/lib.rs +++ b/crates/vite_str/src/lib.rs @@ -5,6 +5,7 @@ use std::{ ops::Deref, path::Path, str::from_utf8, + sync::Arc, }; use bincode::{ @@ -158,6 +159,12 @@ impl From for Str { } } +impl From for Arc { + fn from(value: Str) -> Self { + Arc::from(value.as_str()) + } +} + impl PartialEq<&str> for Str { fn eq(&self, other: &&str) -> bool { self.0 == other diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index a4f09439..67833360 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -1,12 +1,14 @@ -mod user; +pub mod user; -use std::collections::HashSet; +use std::{collections::HashSet, sync::Arc}; use monostate::MustBe; pub use user::{UserCacheConfig, UserConfigFile, UserTaskConfig}; -use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_path::AbsolutePath; use vite_str::Str; +use crate::config::user::UserTaskOptions; + /// Task configuration resolved from `package.json` scripts and/or `vite.config.ts` tasks, /// without considering external factors like additional args from cli or environment variables. /// @@ -16,29 +18,64 @@ use vite_str::Str; /// For example, `cwd` is resolved to absolute ones (no external factor can change it), /// but `command` is not parsed into program and args yet because environment variables in it may need to be expanded. /// -/// `depends_on` is not included here because it's represented in the task graph. +/// `depends_on` is not included here because it's represented by the edges of the task graph. #[derive(Debug)] -pub struct ResolvedUserTaskConfig { - /// The command to run for this task +pub struct ResolvedTaskConfig { + /// The command to run for this task, as a raw string. + /// + /// The command may contain environment variables that need to be expanded later. pub command: Str, - /// The working directory for the task - pub cwd: AbsolutePathBuf, + pub resolved_options: ResolvedTaskOptions, +} +#[derive(Debug)] +pub struct ResolvedTaskOptions { + /// The working directory for the task + pub cwd: Arc, /// Cache-related config. None means caching is disabled. pub cache_config: Option, } +impl ResolvedTaskOptions { + /// Resolves from user-defined options and the directory path where the options are defined. + pub fn resolve(user_options: UserTaskOptions, dir: &Arc) -> Self { + let cwd: Arc = if user_options.cwd_relative_to_package.as_str().is_empty() { + Arc::clone(dir) + } else { + dir.join(user_options.cwd_relative_to_package).into() + }; + let cache_config = match user_options.cache_config { + UserCacheConfig::Disabled { cache: MustBe!(false) } => None, + UserCacheConfig::Enabled { cache: MustBe!(true), envs, mut pass_through_envs } => { + pass_through_envs.extend(DEFAULT_PASSTHROUGH_ENVS.iter().copied().map(Str::from)); + Some(CacheConfig { + env_config: EnvConfig { + fingerprinted_envs: envs.into_iter().collect(), + pass_through_envs: pass_through_envs.into(), + }, + }) + } + }; + Self { cwd, cache_config } + } +} + #[derive(Debug)] pub struct CacheConfig { + pub env_config: EnvConfig, +} + +#[derive(Debug)] +pub struct EnvConfig { /// environment variable names to be fingerprinted and passed to the task, with defaults populated - pub envs: HashSet, + pub fingerprinted_envs: HashSet, /// environment variable names to be passed to the task without fingerprinting, with defaults populated - pub pass_through_envs: HashSet, + pub pass_through_envs: Arc<[Str]>, } #[derive(Debug, thiserror::Error)] -pub enum ResolveTaskError { +pub enum ResolveTaskConfigError { /// Both package.json script and vite.config.* task define commands for the task #[error("Both package.json script and vite.config.* task define commands for the task")] CommandConflict, @@ -48,41 +85,95 @@ pub enum ResolveTaskError { NoCommand, } -impl ResolvedUserTaskConfig { +impl ResolvedTaskConfig { + /// Resolve from package.json script only pub fn resolve_package_json_script( - package_dir: &AbsolutePath, + package_dir: &Arc, package_json_script: &str, ) -> Self { - Self::resolve( - UserTaskConfig::package_json_script_default(), - package_dir, - Some(package_json_script), - ) - .expect("Command conflict/missing for package.json script should never happen") + Self { + command: package_json_script.into(), + resolved_options: ResolvedTaskOptions::resolve(UserTaskOptions::default(), package_dir), + } } /// Resolves from user config, package dir, and package.json script (if any). pub fn resolve( user_config: UserTaskConfig, - package_dir: &AbsolutePath, + package_dir: &Arc, package_json_script: Option<&str>, - ) -> Result { + ) -> Result { let command = match (&user_config.command, package_json_script) { - (Some(_), Some(_)) => return Err(ResolveTaskError::CommandConflict), - (None, None) => return Err(ResolveTaskError::NoCommand), + (Some(_), Some(_)) => return Err(ResolveTaskConfigError::CommandConflict), + (None, None) => return Err(ResolveTaskConfigError::NoCommand), (Some(cmd), None) => cmd.as_ref(), (None, Some(script)) => script, }; - let cwd = package_dir.join(user_config.cwd_relative_to_package); - let cache_config = match user_config.cache_config { - UserCacheConfig::Disabled { cache: MustBe!(false) } => None, - UserCacheConfig::Enabled { cache: MustBe!(true), envs, pass_through_envs } => { - Some(CacheConfig { - envs: envs.into_iter().collect(), - pass_through_envs: pass_through_envs.into_iter().collect(), - }) - } - }; - Ok(Self { command: command.into(), cwd, cache_config }) + Ok(Self { + command: command.into(), + resolved_options: ResolvedTaskOptions::resolve(user_config.options, package_dir), + }) } } + +// Exact matches for common environment variables +// Referenced from Turborepo's implementation: +// https://github.com/vercel/turborepo/blob/26d309f073ca3ac054109ba0c29c7e230e7caac3/crates/turborepo-lib/src/task_hash.rs#L439 +const DEFAULT_PASSTHROUGH_ENVS: &[&str] = &[ + // System and shell + "HOME", + "USER", + "TZ", + "LANG", + "SHELL", + "PWD", + "PATH", + // CI/CD environments + "CI", + // Node.js specific + "NODE_OPTIONS", + "COREPACK_HOME", + "NPM_CONFIG_STORE_DIR", + "PNPM_HOME", + // Library paths + "LD_LIBRARY_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "LIBPATH", + // Terminal/display + "COLORTERM", + "TERM", + "TERM_PROGRAM", + "DISPLAY", + "FORCE_COLOR", + "NO_COLOR", + // Temporary directories + "TMP", + "TEMP", + // Vercel specific + "VERCEL", + "VERCEL_*", + "NEXT_*", + "USE_OUTPUT_FOR_EDGE_FUNCTIONS", + "NOW_BUILDER", + // Windows specific + "APPDATA", + "PROGRAMDATA", + "SYSTEMROOT", + "SYSTEMDRIVE", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + // IDE specific (exact matches) + "ELECTRON_RUN_AS_NODE", + "JB_INTERPRETER", + "_JETBRAINS_TEST_RUNNER_RUN_SCOPE_TYPE", + "JB_IDE_*", + // VSCode specific + "VSCODE_*", + // Docker specific + "DOCKER_*", + "BUILDKIT_*", + "COMPOSE_*", + // Token patterns + "*_TOKEN", +]; diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index e9f42dab..c510316e 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -24,7 +24,7 @@ pub enum UserCacheConfig { /// Environment variable names to be passed to the task without fingerprinting. #[serde(default)] // default to empty if omitted - pass_through_envs: Box<[Str]>, + pass_through_envs: Vec, }, /// Cache is disabled Disabled { @@ -33,13 +33,10 @@ pub enum UserCacheConfig { }, } -/// Task configuration defined by user in `vite.config.*` +/// Options for user-defined tasks in `vite.config.*`, excluding the command. #[derive(Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct UserTaskConfig { - /// If None, the script from `package.json` with the same name will be used - pub command: Option>, - +pub struct UserTaskOptions { /// The working directory for the task, relative to the package root (not workspace root). #[serde(default)] // default to empty if omitted #[serde(rename = "cwd")] @@ -54,22 +51,36 @@ pub struct UserTaskConfig { pub cache_config: UserCacheConfig, } -impl UserTaskConfig { - /// The default user task config for package.json scripts. - pub fn package_json_script_default() -> Self { +impl Default for UserTaskOptions { + /// The default user task options for package.json scripts. + fn default() -> Self { Self { - command: None, + // Runs in the package root cwd_relative_to_package: RelativePathBuf::default(), + // No dependencies depends_on: Arc::new([]), + // Caching enabled with no fingerprinted envs cache_config: UserCacheConfig::Enabled { cache: MustBe!(true), envs: Box::new([]), - pass_through_envs: Box::new([]), + pass_through_envs: Vec::new(), }, } } } +/// Full user-defined task configuration in `vite.config.*`, including the command and options. +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UserTaskConfig { + /// If None, the script from `package.json` with the same name will be used + pub command: Option>, + + /// Fields other than the command + #[serde(flatten)] + pub options: UserTaskOptions, +} + /// User configuration file structure for `vite.config.*` #[derive(Debug, Deserialize)] pub struct UserConfigFile { @@ -90,13 +101,8 @@ mod tests { user_config, UserTaskConfig { command: None, - cwd_relative_to_package: "".try_into().unwrap(), - depends_on: Default::default(), - cache_config: UserCacheConfig::Enabled { - cache: MustBe!(true), - envs: Default::default(), - pass_through_envs: Default::default(), - }, + // A empty task config (`{}`) should be equivalent to not specifying any config at all (just package.json script) + options: UserTaskOptions::default(), } ); } @@ -107,7 +113,7 @@ mod tests { "cwd": "src" }); let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap(); - assert_eq!(user_config.cwd_relative_to_package.as_str(), "src"); + assert_eq!(user_config.options.cwd_relative_to_package.as_str(), "src"); } #[test] @@ -116,7 +122,10 @@ mod tests { "cache": false }); let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap(); - assert_eq!(user_config.cache_config, UserCacheConfig::Disabled { cache: MustBe!(false) }); + assert_eq!( + user_config.options.cache_config, + UserCacheConfig::Disabled { cache: MustBe!(false) } + ); } #[test] diff --git a/crates/vite_task_graph/src/display.rs b/crates/vite_task_graph/src/display.rs new file mode 100644 index 00000000..78afab73 --- /dev/null +++ b/crates/vite_task_graph/src/display.rs @@ -0,0 +1,36 @@ +//! Structs for printing packages and tasks in a human-readable way. It's used in error messages and CLI outputs. + +use std::{fmt::Display, sync::Arc}; + +use vite_path::AbsolutePath; +use vite_str::Str; + +use crate::{IndexedTaskGraph, TaskNodeIndex}; + +/// struct for printing a task in a human-readable way. +#[derive(Debug, Clone)] +pub struct TaskDisplay { + pub package_name: Str, + pub task_name: Str, + pub package_path: Arc, +} + +impl Display for TaskDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // TODO: give an option to display package path as well + write!(f, "{}#{}", self.package_name, self.task_name,) + } +} + +impl IndexedTaskGraph { + /// Get human-readable display for a task node. + pub fn display_task(&self, task_index: TaskNodeIndex) -> TaskDisplay { + let task_node = &self.task_graph()[task_index]; + let package = &self.indexed_package_graph.package_graph()[task_node.task_id.package_index]; + TaskDisplay { + package_name: package.package_json.name.clone(), + task_name: task_node.task_id.task_name.clone(), + package_path: Arc::clone(&package.absolute_path), + } + } +} diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index e4b29fbb..a258dd9b 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod display; pub mod loader; mod package_graph; pub mod query; @@ -10,7 +11,7 @@ use std::{ sync::Arc, }; -use config::{ResolvedUserTaskConfig, UserConfigFile}; +use config::{ResolvedTaskConfig, UserConfigFile}; use package_graph::IndexedPackageGraph; use petgraph::{ graph::{DefaultIx, DiGraph, EdgeIndex, IndexType, NodeIndex}, @@ -23,6 +24,8 @@ use vite_path::AbsolutePath; use vite_str::Str; use vite_workspace::{PackageNodeIndex, WorkspaceRoot}; +use crate::display::TaskDisplay; + #[derive(Debug, Clone, Copy, Serialize)] enum TaskDependencyTypeInner { /// The dependency is explicitly declared by user in `dependsOn`. @@ -58,7 +61,7 @@ pub struct TaskId { /// `package_index` is declared from `task_name` to make the `PartialOrd` implementation group tasks in same packages together. pub package_index: PackageNodeIndex, - /// For user defined tasks, this is the name of the script or the entry in `vite-task.json`. + /// For user defined tasks, this is the name of the script or the entry in `vite.config.*`. /// /// For synthesized tasks, this is the program. pub task_name: Str, @@ -76,7 +79,7 @@ pub struct TaskNode { /// 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, + pub resolved_config: ResolvedTaskConfig, } #[derive(Debug, thiserror::Error)] @@ -84,28 +87,26 @@ pub enum TaskGraphLoadError { #[error("Failed to load package graph: {0}")] PackageGraphLoadError(#[from] vite_workspace::Error), - #[error("Failed to load task config file for package at {package_path:?}: {error}")] + #[error("Failed to load task config file for package at {package_path:?}")] ConfigLoadError { + package_path: Arc, #[source] error: anyhow::Error, - package_path: Arc, }, - #[error("Failed to resolve task config for task {0}#{1}: {2}", package_name, task_name, error)] + #[error("Failed to resolve task config for task {task_display}")] ResolveConfigError { + task_display: TaskDisplay, #[source] - error: crate::config::ResolveTaskError, - package_name: Str, - task_name: Str, + error: crate::config::ResolveTaskConfigError, }, - #[error("Failed to lookup dependency '{specifier}' of task {0} at {1:?}: {error}", origin_task_id.task_name, origin_task_id.task_name)] + #[error("Failed to lookup dependency '{specifier}' for task {task_display}")] DependencySpecifierLookupError { + specifier: Str, + task_display: TaskDisplay, #[source] error: SpecifierLookupError, - specifier: Str, - // Where the dependency specifier is defined - origin_task_id: TaskId, }, } @@ -156,6 +157,7 @@ pub type TaskEdgeIndex = EdgeIndex; /// /// 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. +#[derive(Debug)] pub struct IndexedTaskGraph { task_graph: DiGraph, @@ -168,6 +170,8 @@ pub struct IndexedTaskGraph { node_indices_by_task_id: HashMap, } +pub type TaskGraph = DiGraph; + impl IndexedTaskGraph { /// Load the task graph from a discovered workspace using the provided config loader. pub async fn load( @@ -207,18 +211,21 @@ impl IndexedTaskGraph { let task_id = TaskId { task_name: task_name.clone(), package_index }; - let dependency_specifiers = Arc::clone(&task_user_config.depends_on); + let dependency_specifiers = Arc::clone(&task_user_config.options.depends_on); // Resolve the task configuration combining vite.config.* and package.json script - let resolved_config = ResolvedUserTaskConfig::resolve( + let resolved_config = ResolvedTaskConfig::resolve( task_user_config, &package_dir, package_json_script, ) .map_err(|err| TaskGraphLoadError::ResolveConfigError { error: err, - package_name: package.package_json.name.clone(), - task_name: task_name.clone(), + task_display: TaskDisplay { + package_name: package.package_json.name.clone(), + task_name: task_name.clone(), + package_path: Arc::clone(&package_dir), + }, })?; let task_node = TaskNode { task_id, resolved_config }; @@ -231,7 +238,7 @@ impl IndexedTaskGraph { // For remaining package.json scripts not defined in vite.config.*, create tasks with default config for (script_name, package_json_script) in package_json_scripts.drain() { let task_id = TaskId { task_name: Str::from(script_name), package_index }; - let resolved_config = ResolvedUserTaskConfig::resolve_package_json_script( + let resolved_config = ResolvedTaskConfig::resolve_package_json_script( &package_dir, package_json_script, ); @@ -289,7 +296,7 @@ impl IndexedTaskGraph { .map_err(|error| TaskGraphLoadError::DependencySpecifierLookupError { error, specifier, - origin_task_id: from_task_id.clone(), + task_display: me.display_task(from_node_index), })?; me.task_graph.update_edge( from_node_index, @@ -442,7 +449,7 @@ impl IndexedTaskGraph { Ok(*node_index) } - pub fn task_graph(&self) -> &DiGraph { + pub fn task_graph(&self) -> &TaskGraph { &self.task_graph } @@ -453,4 +460,9 @@ impl IndexedTaskGraph { pub fn get_package_path(&self, package_index: PackageNodeIndex) -> &Arc { &self.indexed_package_graph.package_graph()[package_index].absolute_path } + + pub fn get_package_path_for_task(&self, task_index: TaskNodeIndex) -> &Arc { + let task_node = &self.task_graph[task_index]; + self.get_package_path(task_node.task_id.package_index) + } } diff --git a/crates/vite_task_graph/src/package_graph.rs b/crates/vite_task_graph/src/package_graph.rs index 1c41dfe3..a2692520 100644 --- a/crates/vite_task_graph/src/package_graph.rs +++ b/crates/vite_task_graph/src/package_graph.rs @@ -10,6 +10,7 @@ use vite_str::Str; use vite_workspace::{DependencyType, PackageInfo, PackageIx, PackageNodeIndex}; /// Package graph with additional HashMaps for quick task lookup +#[derive(Debug)] pub struct IndexedPackageGraph { package_graph: DiGraph, diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index 4d3e08c1..eebcc9cc 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -51,11 +51,18 @@ pub struct PackageUnknownError { pub cwd: Arc, } +#[derive(Debug, thiserror::Error)] +pub enum TaskQueryError { + #[error("Failed to look up task from specifier: {specifier}")] + SpecifierLookupError { + specifier: TaskSpecifier, + #[source] + lookup_error: SpecifierLookupError, + }, +} + impl IndexedTaskGraph { - pub fn query_tasks( - &self, - query: TaskQuery, - ) -> Result> { + pub fn query_tasks(&self, query: TaskQuery) -> Result { let mut execution_graph = TaskExecutionGraph::default(); let include_topologicial_deps = match &query.kind { @@ -98,7 +105,10 @@ impl IndexedTaskGraph { ); if nearest_topological_tasks.is_empty() { // No nearest task found, return original error - return Err(err); + return Err(TaskQueryError::SpecifierLookupError { + specifier, + lookup_error: err, + }); } // Add nearest tasks to execution graph // Topological dependencies of nearest tasks will be added later @@ -108,7 +118,10 @@ impl IndexedTaskGraph { } Err(err) => { // Not recoverable by finding nearest package, return error - return Err(err); + return Err(TaskQueryError::SpecifierLookupError { + specifier, + lookup_error: err, + }); } } } diff --git a/crates/vite_task_graph/src/specifier.rs b/crates/vite_task_graph/src/specifier.rs index 3d25de32..5bb2d586 100644 --- a/crates/vite_task_graph/src/specifier.rs +++ b/crates/vite_task_graph/src/specifier.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, str::FromStr}; +use std::{convert::Infallible, fmt::Display, str::FromStr}; use vite_str::Str; @@ -12,6 +12,15 @@ pub struct TaskSpecifier { pub task_name: Str, } +impl Display for TaskSpecifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(package_name) = &self.package_name { + write!(f, "{}#", package_name)? + } + write!(f, "{}", self.task_name) + } +} + impl TaskSpecifier { pub fn parse_raw(raw_specifier: &str) -> Self { if let Some((package_name, task_name)) = raw_specifier.rsplit_once('#') { diff --git a/crates/vite_task_graph/tests/snapshots.rs b/crates/vite_task_graph/tests/snapshots.rs index 877de517..36d2be71 100644 --- a/crates/vite_task_graph/tests/snapshots.rs +++ b/crates/vite_task_graph/tests/snapshots.rs @@ -10,7 +10,7 @@ use vite_str::Str; use vite_task_graph::{ IndexedTaskGraph, SpecifierLookupError, TaskDependencyType, TaskNodeIndex, loader::JsonUserConfigLoader, - query::{PackageUnknownError, TaskExecutionGraph, cli::CLITaskQuery}, + query::{PackageUnknownError, TaskExecutionGraph, TaskQueryError, cli::CLITaskQuery}, }; use vite_workspace::find_workspace_root; @@ -66,7 +66,13 @@ fn snapshot_task_graph( 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(), + cwd: task_node + .resolved_config + .resolved_options + .cwd + .strip_prefix(base_dir) + .unwrap() + .unwrap(), depends_on, }); } @@ -208,7 +214,11 @@ fn run_case(runtime: &Runtime, tmpdir: &AbsolutePath, case_path: &Path) { 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); + match &mut err { + TaskQueryError::SpecifierLookupError { lookup_error, .. } => { + stabilize_specifier_lookup_error(lookup_error, &case_stage_path); + } + } insta::assert_debug_snapshot!(snapshot_name, err); continue; } 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 index 3b2a19c2..0fab07dd 100644 --- 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 @@ -3,14 +3,22 @@ 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", +SpecifierLookupError { + specifier: TaskSpecifier { + package_name: Some( + "@test/a", ), - AbsolutePath( - "//?/workspace/packages/another-a", - ), - ], + task_name: "build", + }, + lookup_error: 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 - 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 index 9e30bc13..c2d9b7b7 100644 --- 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 @@ -3,8 +3,14 @@ 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)), +SpecifierLookupError { + specifier: TaskSpecifier { + package_name: None, + task_name: "non-existent-task", + }, + lookup_error: TaskNameNotFound { + package_name: "@test/a", + task_name: "non-existent-task", + package_index: NodeIndex(PackageIx(0)), + }, } diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml new file mode 100644 index 00000000..ccbc124b --- /dev/null +++ b/crates/vite_task_plan/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vite_task_plan" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +futures-core = { workspace = true } +futures-util = { workspace = true } +petgraph = { workspace = true } +sha2 = { workspace = true } +supports-color = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +vite_glob = { workspace = true } +vite_path = { workspace = true } +vite_shell = { workspace = true } +vite_str = { workspace = true } +vite_task_graph = { workspace = true } diff --git a/crates/vite_task_plan/README.md b/crates/vite_task_plan/README.md new file mode 100644 index 00000000..7a66daa7 --- /dev/null +++ b/crates/vite_task_plan/README.md @@ -0,0 +1,71 @@ +# vite_task_plan + +Execution planning layer for the vite-task monorepo task runner. This crate converts abstract task definitions from the task graph into concrete execution plans ready for execution. + +## Overview + +`vite_task_plan` sits between [`vite_task_graph`](../vite_task_graph) (which defines what tasks exist and their dependencies) and the actual task executor. It resolves all the runtime details needed to execute tasks: + +- Environment variables (fingerprinted and pass-through) +- Working directories +- Command parsing and expansion +- Process spawn configuration +- Caching metadata + +## Key Concepts + +### Execution Plan + +The main output of this crate is an [`ExecutionPlan`](src/lib.rs), which contains a **tree** of task executions with all runtime details resolved. + +```rust +let plan = ExecutionPlan::plan(plan_request, cwd, envs, callbacks).await?; +plan.root_node() // Root execution node +``` + +### Plan Requests + +There are two types of execution requests: + +1. **Query Request** - Execute tasks from the task graph (e.g., `vite run -r build`) + - Queries the task graph based on task patterns + - Builds execution graph with dependency ordering + +2. **Synthetic Request** - Execute on-the-fly tasks not in the graph (e.g., `vite lint`) + - Generated dynamically with provided configuration + - Used for built-in commands + +### Execution Items + +Each task's command is parsed and split into execution items: + +- **Spawn Execution** - Spawns a child process + - Contains: resolved env vars, cwd, program/args or shell script + - Environment resolution for cache fingerprinting + +- **In-Process Execution** - Runs built-in commands in-process + - Optimizes simple commands like `echo` + - No process spawn overhead + +- **Expanded Execution** - Nested execution graph + - Commands like `vite run ...` expand into sub-graphs + - Enables composition of vite commands + +### Command Parsing + +Commands are intelligently parsed: + +```bash +# Single command -> Single spawn execution +"tsc --noEmit" + +# Multiple commands -> Multiple execution items +"tsc --noEmit && vite run test && echo Done" +# ↓ ↓ ↓ +# SpawnExecution Expanded InProcess +``` + +### Error Handling + +- **Recursion Detection** - Prevents infinite task dependency loops +- **Call Stack Tracking** - Maintains task call stack for error reporting diff --git a/crates/vite_task_plan/src/context.rs b/crates/vite_task_plan/src/context.rs new file mode 100644 index 00000000..a15462bc --- /dev/null +++ b/crates/vite_task_plan/src/context.rs @@ -0,0 +1,142 @@ +use std::{ + collections::HashMap, env::JoinPathsError, ffi::OsStr, fmt::Display, ops::Range, sync::Arc, +}; + +use vite_path::AbsolutePath; +use vite_task_graph::{IndexedTaskGraph, TaskNodeIndex, display::TaskDisplay}; + +use crate::{PlanCallbacks, path_env::prepend_path_env}; + +#[derive(Debug, thiserror::Error)] +#[error( + "Detected a recursion in task call stack: the last frame calls the {0}th frame", recursion_point + 1 +)] +pub struct TaskRecursionError { + /// The index in `task_call_stack` where the last frame recurses to. + recursion_point: usize, +} + +/// The context for planning an execution from a task. +#[derive(Debug)] +pub struct PlanContext<'a> { + /// The current working directory. + pub cwd: Arc, + + /// The environment variables for the current execution context. + pub envs: HashMap, Arc>, + + /// The callbacks for loading task graphs and parsing commands. + pub callbacks: &'a mut (dyn PlanCallbacks + 'a), + + /// The current call stack of task index nodes being planned. + pub task_call_stack: Vec<(TaskNodeIndex, Range)>, + + pub indexed_task_graph: &'a IndexedTaskGraph, +} + +/// A human-readable frame in the task call stack. +#[derive(Debug, Clone)] +pub struct TaskCallStackFrameDisplay { + pub task_display: TaskDisplay, + + #[expect(dead_code)] // To be used in terminal error display + pub command_span: Range, +} + +impl Display for TaskCallStackFrameDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // TODO: display command_span + write!(f, "{}", self.task_display) + } +} + +/// A human-readable display of the task call stack. +#[derive(Default, Debug, Clone)] +pub struct TaskCallStackDisplay { + frames: Arc<[TaskCallStackFrameDisplay]>, +} + +impl Display for TaskCallStackDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, frame) in self.frames.iter().enumerate() { + if i > 0 { + write!(f, " -> ")?; + } + write!(f, "{}", frame)?; + } + Ok(()) + } +} + +impl<'a> PlanContext<'a> { + pub fn cwd(&self) -> &Arc { + &self.cwd + } + + pub fn envs(&self) -> &HashMap, Arc> { + &self.envs + } + + /// Get a human-readable display of the current task call stack. + pub fn display_call_stack(&self) -> TaskCallStackDisplay { + TaskCallStackDisplay { + frames: self + .task_call_stack + .iter() + .map(|(idx, span)| TaskCallStackFrameDisplay { + task_display: self.indexed_task_graph.display_task(*idx), + command_span: span.clone(), + }) + .collect(), + } + } + + /// Check if adding the given task node index would create a recursion in the call stack. + pub fn check_recursion( + &self, + task_node_index: TaskNodeIndex, + ) -> Result<(), TaskRecursionError> { + if let Some(recursion_start) = + self.task_call_stack.iter().position(|(idx, _)| *idx == task_node_index) + { + return Err(TaskRecursionError { recursion_point: recursion_start }); + } + Ok(()) + } + + pub fn indexed_task_graph(&self) -> &'a IndexedTaskGraph { + self.indexed_task_graph + } + + /// Push a new frame onto the task call stack. + pub fn push_stack_frame(&mut self, task_node_index: TaskNodeIndex, command_span: Range) { + self.task_call_stack.push((task_node_index, command_span)); + } + + pub fn callbacks(&mut self) -> &mut (dyn PlanCallbacks + '_) { + self.callbacks + } + + pub fn prepend_path(&mut self, path_to_prepend: &AbsolutePath) -> Result<(), JoinPathsError> { + prepend_path_env(&mut self.envs, path_to_prepend) + } + + pub fn add_envs( + &mut self, + new_envs: impl Iterator, impl AsRef)>, + ) { + for (key, value) in new_envs { + self.envs.insert(Arc::from(key.as_ref()), Arc::from(value.as_ref())); + } + } + + pub fn duplicate(&mut self) -> PlanContext<'_> { + PlanContext { + cwd: Arc::clone(&self.cwd), + envs: self.envs.clone(), + callbacks: self.callbacks, + task_call_stack: self.task_call_stack.clone(), + indexed_task_graph: self.indexed_task_graph, + } + } +} diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs new file mode 100644 index 00000000..4ac930ff --- /dev/null +++ b/crates/vite_task_plan/src/envs.rs @@ -0,0 +1,519 @@ +use std::{ + collections::{BTreeMap, HashMap}, + ffi::OsStr, + sync::Arc, +}; + +use sha2::{Digest as _, Sha256}; +use supports_color::{Stream, on}; +use vite_glob::GlobPatternSet; +use vite_str::Str; +use vite_task_graph::config::EnvConfig; + +/// Resolved environment variables for a task to be fingerprinted. +/// +/// Contents of this struct are only for fingerprinting and cache key computation (some of envs may be hashed for security). +/// The actual environment variables to be passed to the execution are in `LeafExecutionItem.all_envs`. +#[derive(Debug)] +pub struct ResolvedEnvs { + /// Environment variables that should be fingerprinted for this execution. + /// + /// Use `BTreeMap` to ensure stable order. + pub fingerprinted_envs: BTreeMap>, + + /// Environment variable names that should be passed through without values being fingerprinted. + /// + /// Names are still included in the fingerprint so that changes to these names can invalidate the cache. + pub pass_through_envs: Arc<[Str]>, +} + +#[derive(Debug, thiserror::Error)] +pub enum ResolveEnvError { + #[error("Failed to resolve envs with invalid glob patterns")] + GlobError { + #[source] + #[from] + glob_error: vite_glob::Error, + }, + + #[error("Env value is not valid unicode: {key} = {value:?}")] + EnvValueIsNotValidUnicode { key: Str, value: Arc }, +} +impl ResolvedEnvs { + /// Resolves from all available envs and env config. + /// + /// Before the call, `all_envs` is expected to contain all available envs. + /// After the call, it will be modified to contain only envs to be passed to the execution (fingerprinted + pass_through). + pub fn resolve( + all_envs: &mut HashMap, Arc>, + env_config: &EnvConfig, + ) -> Result { + // Collect all envs matching fingerpinted or pass-through envs in env_config + *all_envs = { + let mut new_all_envs = resolve_envs_with_patterns( + all_envs.iter(), + &env_config + .pass_through_envs + .iter() + .map(std::convert::AsRef::as_ref) + .chain(env_config.fingerprinted_envs.iter().map(std::convert::AsRef::as_ref)) + .collect::>(), + )?; + + // Automatically add FORCE_COLOR environment variable if not already set + // This enables color output in subprocesses when color is supported + // TODO: will remove this temporarily until we have a better solution + if !new_all_envs.contains_key(OsStr::new("FORCE_COLOR")) + && !new_all_envs.contains_key(OsStr::new("NO_COLOR")) + && let Some(support) = on(Stream::Stdout) + { + let force_color_value = if support.has_16m { + "3" // True color (16 million colors) + } else if support.has_256 { + "2" // 256 colors + } else if support.has_basic { + "1" // Basic ANSI colors + } else { + "0" // No color support + }; + new_all_envs.insert( + OsStr::new("FORCE_COLOR").into(), + Arc::::from(OsStr::new(force_color_value)), + ); + } + new_all_envs + }; + + // Resolve fingerprinted envs + let mut fingerprinted_envs = BTreeMap::>::new(); + if !env_config.fingerprinted_envs.is_empty() { + let fingerprinted_env_patterns = GlobPatternSet::new( + env_config.fingerprinted_envs.iter().filter(|s| !s.starts_with('!')), + )?; + let sensitive_patterns = GlobPatternSet::new(SENSITIVE_PATTERNS)?; + for (name, value) in all_envs.iter() { + let Some(name) = name.to_str() else { + continue; + }; + if !fingerprinted_env_patterns.is_match(name) { + continue; + } + let Some(value) = value.to_str() else { + return Err(ResolveEnvError::EnvValueIsNotValidUnicode { + key: name.into(), + value: Arc::clone(value), + }); + }; + // Hash sensitive env values + let value: Arc = if sensitive_patterns.is_match(name) { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + format!("sha256:{:x}", hasher.finalize()).into() + } else { + value.into() + }; + fingerprinted_envs.insert(name.into(), value); + } + } + + Ok(Self { + fingerprinted_envs, + // Save pass_through_envs names as-is, so any changes to it will invalidate the cache + pass_through_envs: Arc::clone(&env_config.pass_through_envs), + }) + } +} + +fn resolve_envs_with_patterns<'a>( + env_vars: impl Iterator, &'a Arc)>, + patterns: &[&str], +) -> Result, Arc>, vite_glob::Error> { + let patterns = GlobPatternSet::new(patterns.iter().filter(|pattern| { + if pattern.starts_with('!') { + // FIXME: use better way to print warning log + // Or parse and validate TaskConfig in command parsing phase + tracing::warn!( + "env pattern starts with '!' is not supported, will be ignored: {}", + pattern + ); + false + } else { + true + } + }))?; + let envs: HashMap, Arc> = env_vars + .filter_map(|(name, value)| { + let Some(name_str) = name.as_ref().to_str() else { + return None; + }; + + if patterns.is_match(name_str) { + Some((Arc::clone(&name), Arc::clone(&value))) + } else { + None + } + }) + .collect(); + Ok(envs) +} + +const SENSITIVE_PATTERNS: &[&str] = &[ + "*_KEY", + "*_SECRET", + "*_TOKEN", + "*_PASSWORD", + "*_PASS", + "*_PWD", + "*_CREDENTIAL*", + "*_API_KEY", + "*_PRIVATE_*", + "AWS_*", + "GITHUB_*", + "NPM_*TOKEN", + "DATABASE_URL", + "MONGODB_URI", + "REDIS_URL", + "*_CERT*", + // Exact matches for known sensitive names + "PASSWORD", + "SECRET", + "TOKEN", +]; + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn create_test_envs(pairs: Vec<(&str, &str)>) -> HashMap, Arc> { + pairs + .into_iter() + .map(|(k, v)| (Arc::from(OsStr::new(k)), Arc::from(OsStr::new(v)))) + .collect() + } + + fn create_env_config(fingerprinted: &[&str], pass_through: &[&str]) -> EnvConfig { + EnvConfig { + fingerprinted_envs: fingerprinted.iter().map(|s| Str::from(*s)).collect(), + pass_through_envs: Arc::from( + pass_through.iter().map(|s| Str::from(*s)).collect::>(), + ), + } + } + + #[test] + fn test_force_color_auto_detection() { + // Test when FORCE_COLOR is not already set + let mut all_envs = create_test_envs(vec![("PATH", "/usr/bin")]); + let env_config = create_env_config(&[], &["PATH"]); + + let result = ResolvedEnvs::resolve(&mut all_envs, &env_config).unwrap(); + + // FORCE_COLOR should be automatically added if color is supported + // Note: This test might vary based on the test environment + let force_color_present = all_envs.contains_key(OsStr::new("FORCE_COLOR")); + if force_color_present { + let force_color_value = all_envs.get(OsStr::new("FORCE_COLOR")).unwrap(); + let force_color_str = force_color_value.to_str().unwrap(); + // Should be a valid FORCE_COLOR level + assert!(matches!(force_color_str, "0" | "1" | "2" | "3")); + } + + // Test when FORCE_COLOR is already set - should not be overridden + let mut all_envs = create_test_envs(vec![("PATH", "/usr/bin"), ("FORCE_COLOR", "2")]); + let env_config = create_env_config(&[], &["PATH", "FORCE_COLOR"]); + + let _result = ResolvedEnvs::resolve(&mut all_envs, &env_config).unwrap(); + + // Should contain the original FORCE_COLOR value + assert!(all_envs.contains_key(OsStr::new("FORCE_COLOR"))); + let force_color_value = all_envs.get(OsStr::new("FORCE_COLOR")).unwrap(); + assert_eq!(force_color_value.to_str().unwrap(), "2"); + + // FORCE_COLOR should not be in fingerprinted_envs since it's not declared + assert!(!result.fingerprinted_envs.contains_key("FORCE_COLOR")); + + // Test when NO_COLOR is already set - FORCE_COLOR should not be automatically added + let mut all_envs = create_test_envs(vec![("PATH", "/usr/bin"), ("NO_COLOR", "1")]); + let env_config = create_env_config(&[], &["PATH", "NO_COLOR"]); + + let _result = ResolvedEnvs::resolve(&mut all_envs, &env_config).unwrap(); + + assert!(all_envs.contains_key(OsStr::new("NO_COLOR"))); + let no_color_value = all_envs.get(OsStr::new("NO_COLOR")).unwrap(); + assert_eq!(no_color_value.to_str().unwrap(), "1"); + // FORCE_COLOR should not be automatically added since NO_COLOR is set + assert!(!all_envs.contains_key(OsStr::new("FORCE_COLOR"))); + } + + #[test] + #[cfg(unix)] + fn test_task_envs_stable_ordering() { + // Create env config with multiple envs + let env_config = create_env_config( + &["ZEBRA_VAR", "ALPHA_VAR", "MIDDLE_VAR", "BETA_VAR", "NOT_EXISTS_VAR", "APP?_*"], + &["PATH", "HOME", "VSCODE_VAR", "OXLINT_*"], + ); + + // Create mock environment variables + let mock_envs = vec![ + ("ZEBRA_VAR", "zebra_value"), + ("ALPHA_VAR", "alpha_value"), + ("MIDDLE_VAR", "middle_value"), + ("BETA_VAR", "beta_value"), + ("VSCODE_VAR", "vscode_value"), + ("APP1_TOKEN", "app1_token"), + ("APP2_TOKEN", "app2_token"), + ("APP1_NAME", "app1_value"), + ("APP2_NAME", "app2_value"), + ("APP1_PASSWORD", "app1_password"), + ("OXLINT_TSGOLINT_PATH", "/path/to/oxlint_tsgolint"), + ("PATH", "/usr/bin"), + ("HOME", "/home/user"), + ]; + + // Resolve envs multiple times + let mut all_envs1 = create_test_envs(mock_envs.clone()); + let mut all_envs2 = create_test_envs(mock_envs.clone()); + let mut all_envs3 = create_test_envs(mock_envs.clone()); + + let result1 = ResolvedEnvs::resolve(&mut all_envs1, &env_config).unwrap(); + let result2 = ResolvedEnvs::resolve(&mut all_envs2, &env_config).unwrap(); + let result3 = ResolvedEnvs::resolve(&mut all_envs3, &env_config).unwrap(); + + // Convert to vecs for comparison (BTreeMap already maintains stable ordering) + let envs1: Vec<_> = result1.fingerprinted_envs.iter().collect(); + let envs2: Vec<_> = result2.fingerprinted_envs.iter().collect(); + let envs3: Vec<_> = result3.fingerprinted_envs.iter().collect(); + + // Verify all resolutions produce the same result + assert_eq!(envs1, envs2); + assert_eq!(envs2, envs3); + + // Verify all expected variables are present + assert_eq!(envs1.len(), 9); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "ALPHA_VAR")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "BETA_VAR")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "MIDDLE_VAR")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "ZEBRA_VAR")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP1_NAME")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP2_NAME")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP1_PASSWORD")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP1_TOKEN")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP2_TOKEN")); + + // APP1_PASSWORD should be hashed + let password = result1.fingerprinted_envs.get("APP1_PASSWORD").unwrap(); + assert_eq!( + password.as_ref(), + "sha256:17f1ef795d5663faa129f6fe3e5335e67ac7a701d1a70533a5f4b1635413a1aa" + ); + + // Verify pass-through envs are present in all_envs + assert!(all_envs1.contains_key(OsStr::new("VSCODE_VAR"))); + assert!(all_envs1.contains_key(OsStr::new("PATH"))); + assert!(all_envs1.contains_key(OsStr::new("HOME"))); + assert!(all_envs1.contains_key(OsStr::new("OXLINT_TSGOLINT_PATH"))); + } + + #[test] + #[cfg(unix)] + fn test_unix_env_case_sensitive() { + // Test that Unix environment variable matching is case-sensitive + // Create env config with envs in different cases + let env_config = create_env_config(&["TEST_VAR", "test_var", "Test_Var"], &[]); + + // Create mock environment variables with different cases + let mut all_envs = create_test_envs(vec![ + ("TEST_VAR", "uppercase"), + ("test_var", "lowercase"), + ("Test_Var", "mixed"), + ]); + + let result = ResolvedEnvs::resolve(&mut all_envs, &env_config).unwrap(); + let fingerprinted_envs = &result.fingerprinted_envs; + + // On Unix, all three should be treated as separate variables + assert_eq!( + fingerprinted_envs.len(), + 3, + "Unix should treat different cases as different variables" + ); + + assert_eq!(fingerprinted_envs.get("TEST_VAR").map(|s| s.as_ref()), Some("uppercase")); + assert_eq!(fingerprinted_envs.get("test_var").map(|s| s.as_ref()), Some("lowercase")); + assert_eq!(fingerprinted_envs.get("Test_Var").map(|s| s.as_ref()), Some("mixed")); + } + + #[test] + #[cfg(windows)] + fn test_windows_env_case_insensitive() { + let env_config = create_env_config( + &["ZEBRA_VAR", "ALPHA_VAR", "MIDDLE_VAR", "BETA_VAR", "NOT_EXISTS_VAR", "APP?_*"], + &["Path", "VSCODE_VAR"], + ); + + let mock_envs = vec![ + ("ZEBRA_VAR", "zebra_value"), + ("ALPHA_VAR", "alpha_value"), + ("MIDDLE_VAR", "middle_value"), + ("BETA_VAR", "beta_value"), + ("VSCODE_VAR", "vscode_value"), + ("app1_name", "app1_value"), + ("app2_name", "app2_value"), + ("Path", "C:\\Windows\\System32"), + ]; + + let mut all_envs1 = create_test_envs(mock_envs.clone()); + let mut all_envs2 = create_test_envs(mock_envs.clone()); + let mut all_envs3 = create_test_envs(mock_envs.clone()); + + let result1 = ResolvedEnvs::resolve(&mut all_envs1, &env_config).unwrap(); + let result2 = ResolvedEnvs::resolve(&mut all_envs2, &env_config).unwrap(); + let result3 = ResolvedEnvs::resolve(&mut all_envs3, &env_config).unwrap(); + + let envs1: Vec<_> = result1.fingerprinted_envs.iter().collect(); + let envs2: Vec<_> = result2.fingerprinted_envs.iter().collect(); + let envs3: Vec<_> = result3.fingerprinted_envs.iter().collect(); + + assert_eq!(envs1, envs2); + assert_eq!(envs2, envs3); + + assert_eq!(envs1.len(), 6); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "ALPHA_VAR")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "BETA_VAR")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "MIDDLE_VAR")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "ZEBRA_VAR")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "app1_name")); + assert!(envs1.iter().any(|(k, _)| k.as_str() == "app2_name")); + + // Verify pass-through envs are present + assert!(all_envs1.contains_key(OsStr::new("VSCODE_VAR"))); + assert!( + all_envs1.contains_key(OsStr::new("Path")) + || all_envs1.contains_key(OsStr::new("PATH")) + ); + } + + // ============================================ + // New tests for changed/new logic + // ============================================ + + #[test] + fn test_btreemap_stable_fingerprint() { + // Verify BTreeMap produces identical ordering regardless of insertion order + let env_config = create_env_config(&["AAA", "ZZZ", "MMM", "BBB"], &[]); + + // Create envs in different orders + let mut all_envs1 = + create_test_envs(vec![("AAA", "a"), ("ZZZ", "z"), ("MMM", "m"), ("BBB", "b")]); + let mut all_envs2 = + create_test_envs(vec![("ZZZ", "z"), ("BBB", "b"), ("AAA", "a"), ("MMM", "m")]); + + let result1 = ResolvedEnvs::resolve(&mut all_envs1, &env_config).unwrap(); + let result2 = ResolvedEnvs::resolve(&mut all_envs2, &env_config).unwrap(); + + // Both should produce identical iteration order due to BTreeMap + let keys1: Vec<_> = result1.fingerprinted_envs.keys().collect(); + let keys2: Vec<_> = result2.fingerprinted_envs.keys().collect(); + + assert_eq!(keys1, keys2); + // BTreeMap should be sorted alphabetically + assert_eq!(keys1, vec!["AAA", "BBB", "MMM", "ZZZ"]); + } + + #[test] + fn test_pass_through_envs_names_stored() { + let env_config = create_env_config(&["BUILD_MODE"], &["PATH", "HOME", "CI"]); + + let mut all_envs = create_test_envs(vec![ + ("BUILD_MODE", "production"), + ("PATH", "/usr/bin"), + ("HOME", "/home/user"), + ("CI", "true"), + ]); + + let result = ResolvedEnvs::resolve(&mut all_envs, &env_config).unwrap(); + + // Verify pass_through_envs names are stored + assert_eq!(result.pass_through_envs.len(), 3); + assert!(result.pass_through_envs.iter().any(|s| s.as_str() == "PATH")); + assert!(result.pass_through_envs.iter().any(|s| s.as_str() == "HOME")); + assert!(result.pass_through_envs.iter().any(|s| s.as_str() == "CI")); + } + + #[test] + fn test_all_envs_mutated_after_resolve() { + // Include some envs that should be filtered out + let env_config = create_env_config(&["KEEP_THIS"], &["PASS_THROUGH"]); + + let mut all_envs = create_test_envs(vec![ + ("KEEP_THIS", "kept"), + ("PASS_THROUGH", "passed"), + ("FILTER_OUT", "filtered"), + ("ANOTHER_FILTERED", "also filtered"), + ]); + + let _result = ResolvedEnvs::resolve(&mut all_envs, &env_config).unwrap(); + + // all_envs should only contain fingerprinted + pass_through envs (plus auto-added ones) + assert!(all_envs.contains_key(OsStr::new("KEEP_THIS"))); + assert!(all_envs.contains_key(OsStr::new("PASS_THROUGH"))); + assert!(!all_envs.contains_key(OsStr::new("FILTER_OUT"))); + assert!(!all_envs.contains_key(OsStr::new("ANOTHER_FILTERED"))); + } + + #[test] + #[cfg(unix)] + fn test_error_env_value_not_valid_unicode() { + use std::os::unix::ffi::OsStrExt; + + let env_config = create_env_config(&["INVALID_UTF8"], &[]); + + // Create invalid UTF-8 sequence + let invalid_utf8 = OsStr::from_bytes(&[0xff, 0xfe]); + let mut all_envs: HashMap, Arc> = + [(Arc::from(OsStr::new("INVALID_UTF8")), Arc::from(invalid_utf8))] + .into_iter() + .collect(); + + let result = ResolvedEnvs::resolve(&mut all_envs, &env_config); + + assert!(result.is_err()); + match result.unwrap_err() { + ResolveEnvError::EnvValueIsNotValidUnicode { key, .. } => { + assert_eq!(key.as_str(), "INVALID_UTF8"); + } + other => panic!("Expected EnvValueIsNotValidUnicode, got {:?}", other), + } + } + + #[test] + fn test_sensitive_env_hashing() { + // Test various sensitive patterns + let env_config = create_env_config( + &["API_KEY", "MY_SECRET", "AUTH_TOKEN", "DB_PASSWORD", "NORMAL_VAR"], + &[], + ); + + let mut all_envs = create_test_envs(vec![ + ("API_KEY", "secret_key_123"), + ("MY_SECRET", "secret_value"), + ("AUTH_TOKEN", "token_abc"), + ("DB_PASSWORD", "password123"), + ("NORMAL_VAR", "normal_value"), + ]); + + let result = ResolvedEnvs::resolve(&mut all_envs, &env_config).unwrap(); + + // Sensitive envs should be hashed + assert!(result.fingerprinted_envs.get("API_KEY").unwrap().starts_with("sha256:")); + assert!(result.fingerprinted_envs.get("MY_SECRET").unwrap().starts_with("sha256:")); + assert!(result.fingerprinted_envs.get("AUTH_TOKEN").unwrap().starts_with("sha256:")); + assert!(result.fingerprinted_envs.get("DB_PASSWORD").unwrap().starts_with("sha256:")); + + // Non-sensitive env should NOT be hashed + assert_eq!(result.fingerprinted_envs.get("NORMAL_VAR").unwrap().as_ref(), "normal_value"); + } +} diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs new file mode 100644 index 00000000..b656c232 --- /dev/null +++ b/crates/vite_task_plan/src/error.rs @@ -0,0 +1,91 @@ +use std::env::JoinPathsError; + +use vite_task_graph::display::TaskDisplay; + +use crate::{ + context::{PlanContext, TaskCallStackDisplay, TaskRecursionError}, + envs::ResolveEnvError, +}; + +/// Errors that can occur when planning a specific execution from a task . +#[derive(Debug, thiserror::Error)] +pub enum TaskPlanErrorKind { + #[error("Failed to load task graph")] + TaskGraphLoadError( + #[source] + #[from] + vite_task_graph::TaskGraphLoadError, + ), + + #[error("Failed to query tasks from task graph")] + TaskQueryError( + #[source] + #[from] + vite_task_graph::query::TaskQueryError, + ), + + #[error(transparent)] + TaskRecursionDetected(#[from] TaskRecursionError), + + #[error("Invalid vite task command")] + ParsePlanRequestError { + #[source] + error: anyhow::Error, + }, + + #[error("Failed to add node_modules/.bin to PATH environment variable")] + AddNodeModulesBinPathError { + /// This error occurred before parse the command of the task, + /// so the task call stack doesn't contain the current task (no command_span yet). + /// This field is where the error occurred, while the task call stack is the stack leading to it.s + task_display: TaskDisplay, + #[source] + join_paths_error: JoinPathsError, + }, + + #[error("Failed to resolve environment variables")] + ResolveEnvError(#[source] ResolveEnvError), +} + +#[derive(Debug, thiserror::Error)] +#[error("Failed to plan execution, task call stack: {task_call_stack}")] +pub struct Error { + task_call_stack: TaskCallStackDisplay, + + #[source] + kind: TaskPlanErrorKind, +} + +pub(crate) trait TaskPlanErrorKindResultExt { + type Ok; + /// Attach the current task call stack from the planning context to the error. + fn with_plan_context(self, context: &PlanContext<'_>) -> Result; + + /// Attach an empty task call stack to the error. + fn with_empty_call_stack(self) -> Result; +} + +impl TaskPlanErrorKindResultExt for Result { + type Ok = T; + + /// Attach the current task call stack from the planning context to the error. + fn with_plan_context(self, context: &PlanContext<'_>) -> Result { + match self { + Ok(value) => Ok(value), + Err(kind) => { + let task_call_stack = context.display_call_stack(); + Err(Error { task_call_stack, kind }) + } + } + } + + fn with_empty_call_stack(self) -> Result { + match self { + Ok(value) => Ok(value), + Err(kind) => { + let task_call_stack = TaskCallStackDisplay::default(); + Err(Error { task_call_stack, kind }) + } + } + } +} diff --git a/crates/vite_task_plan/src/execution_graph.rs b/crates/vite_task_plan/src/execution_graph.rs new file mode 100644 index 00000000..6ec1c38f --- /dev/null +++ b/crates/vite_task_plan/src/execution_graph.rs @@ -0,0 +1,25 @@ +use petgraph::graph::{DefaultIx, EdgeIndex, IndexType, NodeIndex}; + +use crate::TaskExecution; + +/// newtype of `DefaultIx` for indices in task graphs +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExecutionIx(DefaultIx); +unsafe impl IndexType for ExecutionIx { + fn new(x: usize) -> Self { + Self(DefaultIx::new(x)) + } + + fn index(&self) -> usize { + self.0.index() + } + + fn max() -> Self { + Self(::max()) + } +} + +pub type ExecutionNodeIndex = NodeIndex; +pub type ExecutionEdgeIndex = EdgeIndex; + +pub type ExecutionGraph = petgraph::graph::DiGraph; diff --git a/crates/vite_task_plan/src/in_process.rs b/crates/vite_task_plan/src/in_process.rs new file mode 100644 index 00000000..425fb974 --- /dev/null +++ b/crates/vite_task_plan/src/in_process.rs @@ -0,0 +1,76 @@ +use vite_str::Str; + +/// The output of an in-process execution. +#[derive(Debug)] +pub struct InProcessExecutionOutput { + /// The standard output of the execution. + pub stdout: Vec, + // stderr, exit code, etc can be added later +} + +/// An in-process execution item +#[derive(Debug)] +pub struct InProcessExecution { + kind: InProcessExecutionKind, +} + +impl InProcessExecution { + /// Execute the in-process execution and return the output. + pub async fn execute(&self) -> InProcessExecutionOutput { + match &self.kind { + InProcessExecutionKind::Echo { strings, trailing_newline } => { + let mut stdout = Vec::new(); + for s in strings.iter() { + stdout.extend_from_slice(s.as_bytes()); + stdout.push(b' '); + } + stdout.pop(); // remove last space + if *trailing_newline { + stdout.push(b'\n'); + } + InProcessExecutionOutput { stdout } + } + } + } +} + +/// The kind of an in-process execution. +#[derive(Debug)] +enum InProcessExecutionKind { + /// echo command + Echo { + /// strings to print, spaced by ' ' + strings: Vec, + /// whether to print a trailing newline + trailing_newline: bool, + }, +} + +impl InProcessExecution { + pub fn get_builtin_execution( + name: &str, + mut args: impl Iterator>, + ) -> Option { + match name { + "echo" => { + let mut strings = Vec::new(); + let trailing_newline = if let Some(first_arg) = args.next() { + let first_arg = first_arg.as_ref(); + if first_arg == "-n" { + false + } else { + strings.push(first_arg.into()); + true + } + } else { + true + }; + strings.extend(args.map(|s| s.as_ref().into())); + Some(InProcessExecution { + kind: InProcessExecutionKind::Echo { strings, trailing_newline }, + }) + } + _ => None, + } + } +} diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs new file mode 100644 index 00000000..9247b55f --- /dev/null +++ b/crates/vite_task_plan/src/lib.rs @@ -0,0 +1,173 @@ +mod context; +mod envs; +mod error; +pub mod execution_graph; +mod in_process; +mod path_env; +mod plan; +pub mod plan_request; + +use std::{collections::HashMap, ffi::OsStr, fmt::Debug, ops::Range, sync::Arc}; + +use context::PlanContext; +use envs::ResolvedEnvs; +use error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}; +use execution_graph::ExecutionGraph; +use futures_core::future::BoxFuture; +use in_process::InProcessExecution; +use plan::{plan_query_request, plan_synthetic_request}; +use plan_request::PlanRequest; +use vite_path::AbsolutePath; +use vite_str::Str; +use vite_task_graph::{TaskGraphLoadError, TaskNodeIndex, query::TaskQuery}; + +/// Resolved cache configuration for a spawn execution. +#[derive(Debug)] +pub struct ResolvedCacheConfig { + /// Environment variables that are used for fingerprinting the cache. + pub resolved_envs: ResolvedEnvs, +} + +/// A resolved spawn execution. +/// Unlike tasks in `vite_task_graph`, this struct contains all information needed for execution, +/// like resolved environment variables, current working directory, and additional args from cli. +#[derive(Debug)] +pub struct SpawnExecution { + /// Resolved cache configuration for this execution. `None` means caching is disabled. + pub resolved_cache_config: Option, + + /// Environment variables to set for the command, including both fingerprinted and pass-through envs. + pub all_envs: Arc, Arc>>, + + /// Current working directory + pub cwd: Arc, + + /// parsed program with args or shell script + pub command_kind: SpawnCommandKind, +} + +/// The kind of a spawn command +#[derive(Debug)] +pub enum SpawnCommandKind { + /// A program with args to be executed directly + Program { program: Str, args: Arc<[Str]> }, + /// A script to be executed by os shell (sh or cmd) + ShellScript(Str), +} + +/// Represents how a task should be executed. It's the node type for the execution graph. Each node corresponds to a task. +#[derive(Debug)] +pub struct TaskExecution { + /// The task index in the task graph + pub task_node_index: TaskNodeIndex, + + /// A task's command is split by `&&` and expanded into multiple execution items. + /// + /// It contains a single item if the command has no `&&` + pub items: Vec, +} + +/// An execution item, either expanded from a known vite subcommand, or a spawn execution. +#[derive(Debug)] +pub struct ExecutionItem { + /// The range of the task command that this execution item is resolved from. + /// + /// This field is for displaying purpose only. + /// The actual execution info (if this is spawn) is in `SpawnExecutionItem.command_kind`. + pub command_span: Range, + + /// The kind of this execution item + pub kind: ExecutionItemKind, +} + +/// The kind of a leaf execution item, which cannot be expanded further. +#[derive(Debug)] +pub enum LeafExecutionKind { + /// The execution is a spawn of a child process + Spawn(SpawnExecution), + /// The execution is done in-process by InProcessExecution::execute() + InProcess(InProcessExecution), +} + +/// An execution item, from a split subcommand in a task's command (`item1 && item2 && ...`). +#[derive(Debug)] +pub enum ExecutionItemKind { + /// Expanded from a known vite subcommand, like `vite run ...` or `vite lint`. + Expanded(ExecutionGraph), + /// A normal execution that spawns a child process, like `tsc --noEmit`. + Leaf(LeafExecutionKind), +} + +/// Callbackes needed during planning. +/// See each method for details. +pub trait PlanCallbacks: Debug { + fn load_task_graph( + &mut self, + cwd: &AbsolutePath, + ) -> BoxFuture<'_, Result, TaskGraphLoadError>>; + + /// This is called for every parsable command in the task graph in order to determine how to execute it. + /// + /// `vite_task_plan` doesn't have the knowledge of how cli args should be parsed. It relies on this callback. + /// + /// - If it returns `Err`, the planning will abort with the returned error. + /// - If it returns `Ok(None)`, the command will be spawned as a normal process. + /// - If it returns `Ok(Some(ParsedArgs::TaskQuery)`, the command will be expanded as a `ExpandedExecution` with a task graph queried from the returned `TaskQuery`. + /// - If it returns `Ok(Some(ParsedArgs::Synthetic)`, the command will become a `SpawnExecution` with the synthetic task. + fn get_plan_request( + &self, + program: &str, + args: &[Str], + ) -> BoxFuture<'_, anyhow::Result>>; +} + +#[derive(Debug)] +pub struct ExecutionPlan { + root_node: ExecutionItemKind, +} + +pub struct Args { + pub query: TaskQuery, +} + +impl ExecutionPlan { + pub fn root_node(&self) -> &ExecutionItemKind { + &self.root_node + } + + pub async fn plan( + plan_request: PlanRequest, + cwd: &Arc, + envs: &HashMap, Arc>, + callbacks: &mut (dyn PlanCallbacks + '_), + ) -> Result { + let root_node = match plan_request { + PlanRequest::Query(query_plan_request) => { + let indexed_task_graph = callbacks + .load_task_graph(cwd) + .await + .map_err(|load_error| TaskPlanErrorKind::TaskGraphLoadError(load_error)) + .with_empty_call_stack()?; + + let context = PlanContext { + cwd: Arc::clone(cwd), + envs: envs.clone(), + callbacks, + task_call_stack: Vec::new(), + indexed_task_graph: &indexed_task_graph, + }; + + let execution_graph = plan_query_request(query_plan_request, context).await?; + ExecutionItemKind::Expanded(execution_graph) + } + PlanRequest::Synthetic(synthetic_plan_request) => { + let execution = + plan_synthetic_request(&Default::default(), synthetic_plan_request, cwd, envs) + .with_empty_call_stack()?; + + ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(execution)) + } + }; + Ok(Self { root_node }) + } +} diff --git a/crates/vite_task_plan/src/path_env.rs b/crates/vite_task_plan/src/path_env.rs new file mode 100644 index 00000000..3a246b1b --- /dev/null +++ b/crates/vite_task_plan/src/path_env.rs @@ -0,0 +1,147 @@ +use std::{ + collections::HashMap, + env::{JoinPathsError, join_paths, split_paths}, + ffi::OsStr, + iter, + sync::Arc, +}; + +use vite_path::AbsolutePath; + +pub fn prepend_path_env( + envs: &mut HashMap, Arc>, + path_to_prepend: &AbsolutePath, +) -> Result<(), JoinPathsError> { + // Add node_modules/.bin to PATH + // On Windows, environment variable names are case-insensitive (e.g., "PATH", "Path", "path" are all the same) + // However, Rust's HashMap keys are case-sensitive, so we need to find the existing PATH variable + // regardless of its casing to avoid creating duplicate PATH entries with different casings. + // For example, if the system has "Path", we should use that instead of creating a new "PATH" entry. + let env_path = { + if cfg!(windows) + && let Some(existing_path) = envs.iter_mut().find_map(|(name, value)| { + if name.eq_ignore_ascii_case("path") { Some(value) } else { None } + }) + { + // Found existing PATH variable (with any casing), use it + existing_path + } else { + // On Unix or no existing PATH on Windows, create/get "PATH" entry + envs.entry(Arc::from(OsStr::new("PATH"))) + .or_insert_with(|| Arc::::from(OsStr::new(""))) + } + }; + + let existing_paths = split_paths(env_path); + let paths = iter::once(path_to_prepend.as_path().to_path_buf()).chain(existing_paths.filter( + // remove duplicates + |path| path != path_to_prepend.as_path(), + )); + + let new_path_value = join_paths(paths)?; + *env_path = new_path_value.into(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_envs(pairs: Vec<(&str, &str)>) -> HashMap, Arc> { + pairs + .into_iter() + .map(|(k, v)| (Arc::from(OsStr::new(k)), Arc::from(OsStr::new(v)))) + .collect() + } + + #[test] + #[cfg(windows)] + fn test_windows_path_case_insensitive_mixed_case() { + let mut envs = create_test_envs(vec![("Path", "C:\\existing\\path")]); + let path_to_prepend = + AbsolutePath::new("C:\\workspace\\packages\\app\\node_modules\\.bin").unwrap(); + + prepend_path_env(&mut envs, path_to_prepend).unwrap(); + + // Verify that the original "Path" casing is preserved, not "PATH" + assert!(envs.contains_key(OsStr::new("Path"))); + assert!(!envs.contains_key(OsStr::new("PATH"))); + + // Verify the PATH value has node_modules/.bin prepended + let path_value = envs.get(OsStr::new("Path")).unwrap(); + assert!(path_value.to_str().unwrap().contains("node_modules\\.bin")); + assert!(path_value.to_str().unwrap().contains("C:\\existing\\path")); + + // Verify no duplicate PATH entry was created + let path_like_keys: Vec<_> = envs + .keys() + .filter(|k| k.to_str().map(|s| s.eq_ignore_ascii_case("path")).unwrap_or(false)) + .collect(); + assert_eq!(path_like_keys.len(), 1); + } + + #[test] + #[cfg(windows)] + fn test_windows_path_case_insensitive_uppercase() { + let mut envs = create_test_envs(vec![("PATH", "C:\\existing\\path")]); + let path_to_prepend = + AbsolutePath::new("C:\\workspace\\packages\\app\\node_modules\\.bin").unwrap(); + + prepend_path_env(&mut envs, path_to_prepend).unwrap(); + + // Verify the PATH value has node_modules/.bin prepended + let path_value = envs.get(OsStr::new("PATH")).unwrap(); + assert!(path_value.to_str().unwrap().contains("node_modules\\.bin")); + assert!(path_value.to_str().unwrap().contains("C:\\existing\\path")); + } + + #[test] + #[cfg(windows)] + fn test_windows_path_created_when_missing() { + let mut envs = create_test_envs(vec![]); + let path_to_prepend = + AbsolutePath::new("C:\\workspace\\packages\\app\\node_modules\\.bin").unwrap(); + + prepend_path_env(&mut envs, path_to_prepend).unwrap(); + + // Verify PATH was created with only node_modules/.bin + let path_value = envs.get(OsStr::new("PATH")).unwrap(); + assert!(path_value.to_str().unwrap().contains("node_modules\\.bin")); + } + + #[test] + #[cfg(unix)] + fn test_unix_path_case_sensitive() { + let mut envs = create_test_envs(vec![("PATH", "/existing/path")]); + let path_to_prepend = + AbsolutePath::new("/workspace/packages/app/node_modules/.bin").unwrap(); + + prepend_path_env(&mut envs, path_to_prepend).unwrap(); + + // Verify "PATH" exists and the complete value has node_modules/.bin prepended + let path_value = envs.get(OsStr::new("PATH")).unwrap(); + let path_str = path_value.to_str().unwrap(); + assert!(path_str.contains("node_modules/.bin")); + assert!(path_str.contains("/existing/path")); + + // Verify that on Unix, the code uses exact "PATH" match (case-sensitive) + assert!(!envs.contains_key(OsStr::new("Path"))); + assert!(!envs.contains_key(OsStr::new("path"))); + } + + #[test] + #[cfg(unix)] + fn test_prepend_paths_removes_duplicates() { + let mut envs = create_test_envs(vec![("PATH", "/workspace/node_modules/.bin:/other/path")]); + let path_to_prepend = AbsolutePath::new("/workspace/node_modules/.bin").unwrap(); + + prepend_path_env(&mut envs, path_to_prepend).unwrap(); + + let path_value = envs.get(OsStr::new("PATH")).unwrap(); + let path_str = path_value.to_str().unwrap(); + + // Should only have one occurrence of node_modules/.bin (duplicates removed) + let node_modules_count = path_str.matches("/workspace/node_modules/.bin").count(); + assert_eq!(node_modules_count, 1, "Duplicate paths should be removed"); + } +} diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs new file mode 100644 index 00000000..5c6bf44f --- /dev/null +++ b/crates/vite_task_plan/src/plan.rs @@ -0,0 +1,218 @@ +use std::{ + collections::{BTreeMap, HashMap}, + ffi::OsStr, + sync::Arc, +}; + +use futures_util::FutureExt; +use vite_path::AbsolutePath; +use vite_shell::try_parse_as_and_list; +use vite_str::Str; +use vite_task_graph::{TaskNodeIndex, config::ResolvedTaskOptions}; + +use crate::{ + ExecutionItem, ExecutionItemKind, LeafExecutionKind, PlanContext, ResolvedCacheConfig, + SpawnCommandKind, SpawnExecution, TaskExecution, + envs::ResolvedEnvs, + error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, + execution_graph::{ExecutionGraph, ExecutionNodeIndex}, + in_process::InProcessExecution, + plan_request::{PlanRequest, QueryPlanRequest, SyntheticPlanRequest}, +}; + +async fn plan_task_as_execution_node( + task_node_index: TaskNodeIndex, + mut context: PlanContext<'_>, +) -> Result { + // Check for recursions in the task call stack. + context + .check_recursion(task_node_index) + .map_err(TaskPlanErrorKind::TaskRecursionDetected) + .with_plan_context(&context)?; + + // Prepend {package_path}/node_modules/.bin to PATH + let package_node_modules_bin_path = context + .indexed_task_graph() + .get_package_path_for_task(task_node_index) + .join("node_modules") + .join(".bin"); + context + .prepend_path(&package_node_modules_bin_path) + .map_err(|join_paths_error| TaskPlanErrorKind::AddNodeModulesBinPathError { + task_display: context.indexed_task_graph().display_task(task_node_index), + join_paths_error, + }) + .with_plan_context(&context)?; + + let task_node = &context.indexed_task_graph().task_graph()[task_node_index]; + + // TODO: variable expansion (https://crates.io/crates/shellexpand) BEFORE parsing + let command_str = task_node.resolved_config.command.as_str(); + + let mut items = Vec::::new(); + + // Try to parse the command string as a list of subcommands separated by `&&` + if let Some(parsed_subcommands) = try_parse_as_and_list(command_str) { + for (and_item, add_item_span) in parsed_subcommands { + // Duplicate the context before modifying it for each and_item + let mut context = context.duplicate(); + context.push_stack_frame(task_node_index, add_item_span.clone()); + + // Check for builtin commands like `echo ...` + if let Some(builtin_execution) = + InProcessExecution::get_builtin_execution(&and_item.program, and_item.args.iter()) + { + items.push(ExecutionItem { + command_span: add_item_span, + kind: ExecutionItemKind::Leaf(LeafExecutionKind::InProcess(builtin_execution)), + }); + continue; + } + + // Try to parse the args of an and_item to a task request like `run -r build` + let task_request = context + .callbacks() + .get_plan_request(&and_item.program, &and_item.args) + .await + .map_err(|error| TaskPlanErrorKind::ParsePlanRequestError { error }) + .with_plan_context(&context)?; + + let execution_item_kind: ExecutionItemKind = match task_request { + // Expand task query like `vite run -r build` + Some(PlanRequest::Query(query_plan_request)) => { + // Add prefix envs to the context + context.add_envs(and_item.envs.iter()); + let execution_graph = plan_query_request(query_plan_request, context).await?; + ExecutionItemKind::Expanded(execution_graph) + } + // Synthetic task, like `vite lint` + Some(PlanRequest::Synthetic(synthetic_plan_request)) => { + let spawn_execution = plan_synthetic_request( + &and_item.envs, + synthetic_plan_request, + context.cwd(), + context.envs(), + ) + .with_plan_context(&context)?; + ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) + } + // Normal 3rd party tool command (like `tsc --noEmit`) + None => { + let spawn_execution = plan_spawn_execution( + &and_item.envs, + SpawnCommandKind::Program { + program: and_item.program, + args: and_item.args.into(), + }, + &task_node.resolved_config.resolved_options, + context.envs(), + ) + .with_plan_context(&context)?; + ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) + } + }; + items.push(ExecutionItem { command_span: add_item_span, kind: execution_item_kind }); + } + } else { + let spawn_execution = plan_spawn_execution( + &BTreeMap::new(), + SpawnCommandKind::ShellScript(command_str.into()), + &task_node.resolved_config.resolved_options, + context.envs(), + ) + .with_plan_context(&context)?; + items.push(ExecutionItem { + command_span: 0..command_str.len(), + kind: ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)), + }); + } + + Ok(TaskExecution { task_node_index, items }) +} + +pub fn plan_synthetic_request( + prefix_envs: &BTreeMap, + synthetic_plan_request: SyntheticPlanRequest, + cwd: &Arc, + envs: &HashMap, Arc>, +) -> Result { + let resolved_options = ResolvedTaskOptions::resolve(synthetic_plan_request.task_options, &cwd); + plan_spawn_execution(prefix_envs, synthetic_plan_request.command_kind, &resolved_options, envs) +} + +fn plan_spawn_execution( + prefix_envs: &BTreeMap, + command_kind: SpawnCommandKind, + resolved_task_options: &ResolvedTaskOptions, + envs: &HashMap, Arc>, +) -> Result { + // all envs available in the current context + let mut all_envs = envs.clone(); + + let mut resolved_cache_config = None; + if let Some(cache_config) = &resolved_task_options.cache_config { + // Resolve envs according cache configs + let mut resolved_envs = ResolvedEnvs::resolve(&mut all_envs, &cache_config.env_config) + .map_err(TaskPlanErrorKind::ResolveEnvError)?; + + // Add prefix envs to fingerprinted envs + resolved_envs + .fingerprinted_envs + .extend(prefix_envs.iter().map(|(name, value)| (name.clone(), value.as_str().into()))); + resolved_cache_config = Some(ResolvedCacheConfig { resolved_envs }); + } + + // Add prefix envs to all envs + all_envs.extend(prefix_envs.iter().map(|(name, value)| { + (OsStr::new(name.as_str()).into(), OsStr::new(value.as_str()).into()) + })); + + Ok(SpawnExecution { + all_envs: Arc::new(all_envs), + resolved_cache_config, + cwd: Arc::clone(&resolved_task_options.cwd), + command_kind, + }) +} + +/// Expand the parsed task request (like `run -r build`/`exec tsc`/`lint`) into an execution graph. +pub async fn plan_query_request( + query_plan_request: QueryPlanRequest, + mut context: PlanContext<'_>, +) -> Result { + // Query matching tasks from the task graph + let task_node_index_graph = context + .indexed_task_graph() + .query_tasks(query_plan_request.query) + .map_err(TaskPlanErrorKind::TaskQueryError) + .with_plan_context(&context)?; + + let mut execution_node_indices_by_task_index = + HashMap::::with_capacity( + task_node_index_graph.node_count(), + ); + + let mut execution_graph = ExecutionGraph::with_capacity( + task_node_index_graph.node_count(), + task_node_index_graph.edge_count(), + ); + + // Plan each task node as execution nodes + for task_index in task_node_index_graph.nodes() { + let task_execution = + plan_task_as_execution_node(task_index, context.duplicate()).boxed_local().await?; + execution_node_indices_by_task_index + .insert(task_index, execution_graph.add_node(task_execution)); + } + + // Add edges between execution nodes according to task dependencies + for (from_task_index, to_task_index, ()) in task_node_index_graph.all_edges() { + execution_graph.add_edge( + execution_node_indices_by_task_index[&from_task_index], + execution_node_indices_by_task_index[&to_task_index], + (), + ); + } + + Ok(execution_graph) +} diff --git a/crates/vite_task_plan/src/plan_request.rs b/crates/vite_task_plan/src/plan_request.rs new file mode 100644 index 00000000..589dd536 --- /dev/null +++ b/crates/vite_task_plan/src/plan_request.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use vite_str::Str; +use vite_task_graph::{config::user::UserTaskOptions, query::TaskQuery}; + +use crate::SpawnCommandKind; + +#[derive(Debug)] +pub struct PlanOptions { + pub extra_args: Arc<[Str]>, +} + +#[derive(Debug)] +pub struct QueryPlanRequest { + /// The query to run against the task graph. For example: `-r build` + pub query: TaskQuery, + + /// Other options affecting the planning process, not the task graph querying itself. + /// + /// For example: `-- arg1 arg2` + pub plan_options: PlanOptions, +} + +/// The request to run a synthetic task, like `vite lint` or `vite exec ...` +/// Synthetic tasks are not defined in the task graph, but are generated on-the-fly. +#[derive(Debug)] +pub struct SyntheticPlanRequest { + /// The command to execute + pub command_kind: SpawnCommandKind, + + /// The task options as if it's defined in `vite.config.*` + pub task_options: UserTaskOptions, +} + +#[derive(Debug)] +pub enum PlanRequest { + /// The request to run tasks queried from the task graph, like `vite run ...` or `vite run-many ...`. + Query(QueryPlanRequest), + /// The request to run a synthetic task (not defined in the task graph), like `vite lint` or `vite exec ...`. + Synthetic(SyntheticPlanRequest), +}