From c358fbaa705bb0d4893ce969670e6dd10e72f581 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 7 Dec 2025 10:08:32 +0800 Subject: [PATCH 01/26] feat: task plan --- crates/vite_task_plan/Cargo.toml | 12 ++++++++++++ crates/vite_task_plan/src/main.rs | 3 +++ 2 files changed, 15 insertions(+) create mode 100644 crates/vite_task_plan/Cargo.toml create mode 100644 crates/vite_task_plan/src/main.rs diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml new file mode 100644 index 00000000..d321bc6b --- /dev/null +++ b/crates/vite_task_plan/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "vite_task_plan" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/vite_task_plan/src/main.rs b/crates/vite_task_plan/src/main.rs new file mode 100644 index 00000000..e7a11a96 --- /dev/null +++ b/crates/vite_task_plan/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} From b36984dea29dd19ad3935b951a290031997bfdd7 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 7 Dec 2025 16:34:02 +0800 Subject: [PATCH 02/26] update # Conflicts: # Cargo.lock # Cargo.toml # crates/vite_shell/Cargo.toml # crates/vite_shell/src/lib.rs --- Cargo.lock | 10 +++++ Cargo.toml | 1 + crates/vite_task_plan/Cargo.toml | 9 ++++- crates/vite_task_plan/src/envs.rs | 17 ++++++++ crates/vite_task_plan/src/lib.rs | 65 +++++++++++++++++++++++++++++++ crates/vite_task_plan/src/main.rs | 3 -- 6 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 crates/vite_task_plan/src/envs.rs create mode 100644 crates/vite_task_plan/src/lib.rs delete mode 100644 crates/vite_task_plan/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 91e7815e..b5f46234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3228,6 +3228,16 @@ dependencies = [ "vite_workspace", ] +[[package]] +name = "vite_task_plan" +version = "0.1.0" +dependencies = [ + "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_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index d321bc6b..94844aa7 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -4,9 +4,14 @@ version = "0.1.0" authors.workspace = true edition.workspace = true license.workspace = true +publish = false rust-version.workspace = true -[dependencies] - [lints] workspace = true + +[dependencies] +vite_path = { workspace = true } +vite_shell = { workspace = true } +vite_str = { workspace = true } +vite_task_graph = { workspace = true } diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs new file mode 100644 index 00000000..5fc16032 --- /dev/null +++ b/crates/vite_task_plan/src/envs.rs @@ -0,0 +1,17 @@ +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, +}; + +use vite_str::Str; + +/// Resolved environment variables for a command +#[derive(Debug)] +pub struct ResolvedEnvs { + /// Environment variables that should be fingerprinted + /// Use BTreeMap to ensure stable order + pub fingerprinted_envs: BTreeMap>, + + /// Environment variables that should be passed through without being fingerprinted + pub pass_through_envs: HashMap>, +} diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs new file mode 100644 index 00000000..566e48aa --- /dev/null +++ b/crates/vite_task_plan/src/lib.rs @@ -0,0 +1,65 @@ +mod envs; + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + sync::Arc, +}; + +use envs::ResolvedEnvs; +use vite_path::AbsolutePath; +use vite_shell::ParsedScript; +use vite_str::Str; +use vite_task_graph::TaskNodeIndex; + +/// Where a resolved command originates from +#[derive(Debug)] +pub enum ResolvedCommandOrigin { + /// the command originates from a task + Task { + /// the task index in the task graph + task_index: TaskNodeIndex, + /// the index of the subcommand in parsed script `subcommand0 && subcommand1 ...`. + /// + /// 0 if the script is not parsable. + subcommand: usize, + }, + + /// the command originates from an synthetic command, like `oxlint ...` synthesized from `vite lint` + Synthetic { + /// the name of the synthetic command + name: Str, + }, +} + +/// A resolved environment variable value for a command +#[derive(Debug)] +struct ResolvedEnvValue { + /// The value of the environment variable + pub value: Str, + /// Whether the environment variable should be passed through without being fingerprinted + pub is_pass_through: bool, +} + +/// A resolved command ready for execution +#[derive(Debug)] +pub struct ResolvedCommand { + /// Where this resolved command originates from + origin: ResolvedCommandOrigin, + + /// Environment variables to set for the command + resolved_envs: ResolvedEnvs, + + /// Current working directory + cwd: Arc, + + /// parsed program with args or shell script + kind: ResolvedCommandKind, +} + +#[derive(Debug)] +pub enum ResolvedCommandKind { + Parsed { program: Str, args: Arc<[Str]> }, + ShellScript(Str), +} + +pub struct ExecutionNode {} diff --git a/crates/vite_task_plan/src/main.rs b/crates/vite_task_plan/src/main.rs deleted file mode 100644 index e7a11a96..00000000 --- a/crates/vite_task_plan/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -} From a17e20980ef7706ab22dffcf648df16e7c2f5cbb Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 9 Dec 2025 21:35:13 +0800 Subject: [PATCH 03/26] wip --- Cargo.lock | 3 + crates/vite_task_graph/src/lib.rs | 2 +- crates/vite_task_plan/Cargo.toml | 3 + crates/vite_task_plan/src/lib.rs | 115 ++++++++++++++++++++++-------- 4 files changed, 94 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5f46234..fbf18428 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3232,6 +3232,9 @@ dependencies = [ name = "vite_task_plan" version = "0.1.0" dependencies = [ + "futures-core", + "futures-util", + "petgraph", "vite_path", "vite_shell", "vite_str", diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index e4b29fbb..3b818000 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -58,7 +58,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, diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index 94844aa7..e0892d67 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -11,6 +11,9 @@ rust-version.workspace = true workspace = true [dependencies] +futures-core = { workspace = true } +futures-util = { workspace = true } +petgraph = { workspace = true } vite_path = { workspace = true } vite_shell = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 566e48aa..abb47be7 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -1,21 +1,21 @@ mod envs; -use std::{ - collections::{BTreeMap, BTreeSet, HashMap}, - sync::Arc, -}; +use std::{collections::HashMap, fmt::Debug, sync::Arc}; use envs::ResolvedEnvs; +use futures_core::future::BoxFuture; +use futures_util::FutureExt; +use petgraph::graph::DiGraph; use vite_path::AbsolutePath; use vite_shell::ParsedScript; use vite_str::Str; -use vite_task_graph::TaskNodeIndex; +use vite_task_graph::{IndexedTaskGraph, TaskNodeIndex, query::TaskQuery}; -/// Where a resolved command originates from +/// Where an execution originates from #[derive(Debug)] -pub enum ResolvedCommandOrigin { - /// the command originates from a task - Task { +pub enum ExecutionOrigin { + /// the execution originates from the task graph (defined in `package.json` or `vite.config.*`) + TaskGraph { /// the task index in the task graph task_index: TaskNodeIndex, /// the index of the subcommand in parsed script `subcommand0 && subcommand1 ...`. @@ -26,40 +26,99 @@ pub enum ResolvedCommandOrigin { /// the command originates from an synthetic command, like `oxlint ...` synthesized from `vite lint` Synthetic { - /// the name of the synthetic command + /// the name of the synthetic command. + /// This is going to be part of associated task name in cache, so that a second `vite lint` can + /// report cache miss compared to the first one. name: Str, }, } -/// A resolved environment variable value for a command +/// A resolved leaf 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)] -struct ResolvedEnvValue { - /// The value of the environment variable - pub value: Str, - /// Whether the environment variable should be passed through without being fingerprinted - pub is_pass_through: bool, -} - -/// A resolved command ready for execution -#[derive(Debug)] -pub struct ResolvedCommand { +pub struct LeafExecution { /// Where this resolved command originates from - origin: ResolvedCommandOrigin, + pub origin: ExecutionOrigin, /// Environment variables to set for the command - resolved_envs: ResolvedEnvs, + pub resolved_envs: ResolvedEnvs, /// Current working directory - cwd: Arc, + pub cwd: Arc, /// parsed program with args or shell script - kind: ResolvedCommandKind, + pub kind: LeafExecutionKind, } +/// The kind of a leaf execution #[derive(Debug)] -pub enum ResolvedCommandKind { - Parsed { program: Str, args: Arc<[Str]> }, +pub enum LeafExecutionKind { + /// A program with args to be executed directly + Program { program: Str, args: Arc<[Str]> }, + /// A script to be executed by os shell ShellScript(Str), } -pub struct ExecutionNode {} +/// A group execution containing a graph of sub-executions, expanded from a parsed script, like `vite run ...` or `vite lint`. +#[derive(Debug)] +pub struct GroupExecution { + /// The script that this group is expanded from. For displaying purpose. + expanded_from: ParsedScript, + + /// The expanded execution nodes in this group + execution_graph: DiGraph, +} + +/// An execution node, either a group or a resolved command +#[derive(Debug)] +pub enum ExecutionNode { + /// A group of execution nodes, expanded from a parsed script, like `vite run ...` or `vite lint`. + Group(GroupExecution), + /// A leaf execution ready, like `tsc --noEmit`. + Leaf(LeafExecution), +} + +pub trait PlanCallbacks: Debug { + fn load_task_graph<'s>( + &'s mut self, + ) -> BoxFuture<'s, Result<&'s IndexedTaskGraph, vite_task_graph::TaskGraphLoadError>>; + fn parse_args(&mut self, program: &str, args: &[Str]) -> ParsedArgs; +} + +/// The context for planning an execution from a task. +#[derive(Debug)] +pub struct PlanContext<'a> { + pub cwd: Arc, + pub envs: HashMap>, + pub callbacks: &'a dyn PlanCallbacks, +} + +#[derive(Debug)] +pub enum ParsedArgs { + QueryTaskGraph { query: TaskQuery, plan_options: PlanOptions }, + Synthetic { name: Str, extra_args: Arc<[Str]> }, +} + +#[derive(Debug)] +pub struct PlanOptions { + pub extra_args: Arc<[Str]>, +} + +#[derive(Debug)] +pub struct ExecutionPlan { + /// The plan starts from a root group, expanded from the cli args + root_group: GroupExecution, +} + +pub struct Args { + pub query: TaskQuery, +} + +impl ExecutionPlan { + pub fn root_group(&self) -> &GroupExecution { + &self.root_group + } + + pub async fn plan(&self, args: ParsedArgs, context: PlanContext<'_>) {} +} From c2ad7c0d8e4ffaf202fcbfde41dfc6ee54cf48b0 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 10 Dec 2025 18:29:47 +0800 Subject: [PATCH 04/26] wip --- Cargo.lock | 2 + crates/vite_task_graph/src/query/mod.rs | 25 ++++-- crates/vite_task_graph/src/specifier.rs | 11 ++- crates/vite_task_graph/tests/snapshots.rs | 8 +- crates/vite_task_plan/Cargo.toml | 2 + crates/vite_task_plan/src/expand.rs | 66 ++++++++++++++++ crates/vite_task_plan/src/leaf/mod.rs | 1 + crates/vite_task_plan/src/lib.rs | 92 +++++++++++++++++------ 8 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 crates/vite_task_plan/src/expand.rs create mode 100644 crates/vite_task_plan/src/leaf/mod.rs diff --git a/Cargo.lock b/Cargo.lock index fbf18428..664f4ca5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3232,9 +3232,11 @@ dependencies = [ name = "vite_task_plan" version = "0.1.0" dependencies = [ + "anyhow", "futures-core", "futures-util", "petgraph", + "thiserror 2.0.17", "vite_path", "vite_shell", "vite_str", 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..e33526ac 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; @@ -208,7 +208,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_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index e0892d67..5c7907f6 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -11,9 +11,11 @@ rust-version.workspace = true workspace = true [dependencies] +anyhow = { workspace = true } futures-core = { workspace = true } futures-util = { workspace = true } petgraph = { workspace = true } +thiserror = { workspace = true } vite_path = { workspace = true } vite_shell = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_task_plan/src/expand.rs b/crates/vite_task_plan/src/expand.rs new file mode 100644 index 00000000..268ba51d --- /dev/null +++ b/crates/vite_task_plan/src/expand.rs @@ -0,0 +1,66 @@ +use petgraph::graph::DiGraph; + +use crate::{ExecutionGraphNode, ExpansionArgs, PlanContext}; + +/* + + +#[derive(Debug, thiserror::Error)] +pub enum ExecutionExpansionError { + #[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, + ), +} + +impl ExpandedExecutionItem { + pub async fn expand_from( + parsed_args: ExpansionArgs, + context: PlanContext<'_>, + ) -> Result { + match parsed_args { + ExpansionArgs::QueryTaskGraph { query, plan_options: _ } => { + // Load the task graph + let indexed_task_graph = context.callbacks.load_task_graph().await?; + + // Expand the task query into execution graph + let task_execution_graph = indexed_task_graph.query_tasks(query)?; + + // Resolve each task node into execution nodes + let task_graph = indexed_task_graph.task_graph(); + for (from_task_index, to_task_index, ()) in task_execution_graph.all_edges() { + let from_task = &task_graph[from_task_index]; + let to_task = &task_graph[to_task_index]; + } + } + ExpansionArgs::Synthetic { name, extra_args } => { + todo!() + } + } + todo!() + } +} + +*/ + +pub async fn expand_into_execution_graph( + expansion_args: ExpansionArgs, + context: PlanContext<'_>, +) -> DiGraph { + match expansion_args { + ExpansionArgs::QueryTaskGraph { query, plan_options } => { + let indexed_task_graph = + context.callbacks.load_task_graph().await.expect("Failed to load task graph"); + } + ExpansionArgs::Synthetic { name, extra_args } => {} + } + todo!() +} diff --git a/crates/vite_task_plan/src/leaf/mod.rs b/crates/vite_task_plan/src/leaf/mod.rs new file mode 100644 index 00000000..82a3da18 --- /dev/null +++ b/crates/vite_task_plan/src/leaf/mod.rs @@ -0,0 +1 @@ +pub fn a() {} diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index abb47be7..543b6929 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -1,4 +1,6 @@ mod envs; +mod expand; +mod leaf; use std::{collections::HashMap, fmt::Debug, sync::Arc}; @@ -9,7 +11,7 @@ use petgraph::graph::DiGraph; use vite_path::AbsolutePath; use vite_shell::ParsedScript; use vite_str::Str; -use vite_task_graph::{IndexedTaskGraph, TaskNodeIndex, query::TaskQuery}; +use vite_task_graph::{IndexedTaskGraph, TaskNode, TaskNodeIndex, query::TaskQuery}; /// Where an execution originates from #[derive(Debug)] @@ -37,7 +39,7 @@ pub enum ExecutionOrigin { /// 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 LeafExecution { +pub struct LeafExecutionItem { /// Where this resolved command originates from pub origin: ExecutionOrigin, @@ -51,6 +53,17 @@ pub struct LeafExecution { pub kind: LeafExecutionKind, } +pub enum LeafTaskResolutionError {} + +impl LeafExecutionItem { + pub fn resolve_from_task( + task_node: &TaskNode, + context: PlanContext<'_>, + ) -> Result { + todo!() + } +} + /// The kind of a leaf execution #[derive(Debug)] pub enum LeafExecutionKind { @@ -60,30 +73,65 @@ pub enum LeafExecutionKind { ShellScript(Str), } -/// A group execution containing a graph of sub-executions, expanded from a parsed script, like `vite run ...` or `vite lint`. +/// A node in the execution graph, coresponding to a task. #[derive(Debug)] -pub struct GroupExecution { - /// The script that this group is expanded from. For displaying purpose. - expanded_from: ParsedScript, +pub struct ExecutionGraphNode { + /// The task index in the task graph + pub task_index: TaskNodeIndex, + + /// A task's command is splitted by `&&` and expanded into multiple execution items. + /// + /// It contains a single item if the command has no `&&` + pub items: Vec, +} - /// The expanded execution nodes in this group - execution_graph: DiGraph, +#[derive(Debug)] +pub enum ExecutionItemScript { + Parsed(ParsedScript), + ShellScript(Str), } -/// An execution node, either a group or a resolved command +/// An execution item, either expanded from a known vite subcommand, or a leaf execution. #[derive(Debug)] -pub enum ExecutionNode { - /// A group of execution nodes, expanded from a parsed script, like `vite run ...` or `vite lint`. - Group(GroupExecution), - /// A leaf execution ready, like `tsc --noEmit`. - Leaf(LeafExecution), +pub struct ExecutionItem { + /// The script that this execution item is resolved from. + /// + /// This field is for displaying purpose only. The actual execution info is in `kind`. + pub script: ExecutionItemScript, + + /// The kind of this execution item + pub kind: ExecutionItemKind, } +/// An execution item, from a splitted 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(DiGraph), + /// A normal leaf execution, like `tsc --noEmit`. + Leaf(LeafExecutionItem), +} + +/// Callbackes needed during planning. +/// See each method for details. pub trait PlanCallbacks: Debug { fn load_task_graph<'s>( &'s mut self, ) -> BoxFuture<'s, Result<&'s IndexedTaskGraph, vite_task_graph::TaskGraphLoadError>>; - fn parse_args(&mut self, program: &str, args: &[Str]) -> ParsedArgs; + + /// This is called for every parsable command in order to determine how to expand 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::QueryTaskGraph)`, 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 expanded as a `ExpandedExecution` with a task graph containing the synthetic task. + fn parse_into_expansion_args( + &mut self, + program: &str, + args: &[Str], + ) -> anyhow::Result>; } /// The context for planning an execution from a task. @@ -91,11 +139,12 @@ pub trait PlanCallbacks: Debug { pub struct PlanContext<'a> { pub cwd: Arc, pub envs: HashMap>, - pub callbacks: &'a dyn PlanCallbacks, + pub callbacks: &'a mut dyn PlanCallbacks, } +/// The parsed cli arguments for expansion. #[derive(Debug)] -pub enum ParsedArgs { +pub enum ExpansionArgs { QueryTaskGraph { query: TaskQuery, plan_options: PlanOptions }, Synthetic { name: Str, extra_args: Arc<[Str]> }, } @@ -107,8 +156,7 @@ pub struct PlanOptions { #[derive(Debug)] pub struct ExecutionPlan { - /// The plan starts from a root group, expanded from the cli args - root_group: GroupExecution, + root_node: ExecutionItemKind, } pub struct Args { @@ -116,9 +164,9 @@ pub struct Args { } impl ExecutionPlan { - pub fn root_group(&self) -> &GroupExecution { - &self.root_group + pub fn root_node(&self) -> &ExecutionItemKind { + &self.root_node } - pub async fn plan(&self, args: ParsedArgs, context: PlanContext<'_>) {} + pub async fn plan(&self, args: ExpansionArgs, context: PlanContext<'_>) {} } From dcd4dedc3402e82901dc83ca52e7323ae1776b48 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 12 Dec 2025 17:27:17 +0800 Subject: [PATCH 05/26] wip --- Cargo.lock | 4 + crates/vite_str/src/lib.rs | 7 + crates/vite_task_graph/src/config/mod.rs | 82 +++++- crates/vite_task_graph/src/config/user.rs | 4 +- crates/vite_task_graph/src/lib.rs | 4 +- .../packages/test-package/vite.config.json | 3 +- crates/vite_task_plan/Cargo.toml | 4 + crates/vite_task_plan/src/envs.rs | 242 +++++++++++++++++- crates/vite_task_plan/src/error.rs | 29 +++ crates/vite_task_plan/src/expand.rs | 107 +++++++- crates/vite_task_plan/src/lib.rs | 149 +++++++---- 11 files changed, 558 insertions(+), 77 deletions(-) create mode 100644 crates/vite_task_plan/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 664f4ca5..908bcf4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3236,7 +3236,11 @@ dependencies = [ "futures-core", "futures-util", "petgraph", + "sha2", + "supports-color", "thiserror 2.0.17", + "tracing", + "vite_glob", "vite_path", "vite_shell", "vite_str", 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..78031218 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -1,6 +1,6 @@ mod user; -use std::collections::HashSet; +use std::{collections::HashSet, sync::Arc}; use monostate::MustBe; pub use user::{UserCacheConfig, UserConfigFile, UserTaskConfig}; @@ -31,10 +31,15 @@ pub struct ResolvedUserTaskConfig { #[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)] @@ -76,13 +81,78 @@ impl ResolvedUserTaskConfig { 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 } => { + UserCacheConfig::Enabled { cache: MustBe!(true), envs, mut pass_through_envs } => { + pass_through_envs.extend(DEFAULT_PASSTHROUGH_ENVS.iter().copied().map(Str::from)); Some(CacheConfig { - envs: envs.into_iter().collect(), - pass_through_envs: pass_through_envs.into_iter().collect(), + env_config: EnvConfig { + fingerprinted_envs: envs.into_iter().collect(), + pass_through_envs: pass_through_envs.into(), + }, }) } }; Ok(Self { command: command.into(), cwd, cache_config }) } } + +// 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..45d9a4b8 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 { @@ -64,7 +64,7 @@ impl UserTaskConfig { cache_config: UserCacheConfig::Enabled { cache: MustBe!(true), envs: Box::new([]), - pass_through_envs: Box::new([]), + pass_through_envs: Vec::new(), }, } } diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index 3b818000..3d64090f 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -168,6 +168,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( @@ -442,7 +444,7 @@ impl IndexedTaskGraph { Ok(*node_index) } - pub fn task_graph(&self) -> &DiGraph { + pub fn task_graph(&self) -> &TaskGraph { &self.task_graph } diff --git a/crates/vite_task_graph/tests/fixtures/conflict-test/packages/test-package/vite.config.json b/crates/vite_task_graph/tests/fixtures/conflict-test/packages/test-package/vite.config.json index aca6743c..e9ed9643 100644 --- a/crates/vite_task_graph/tests/fixtures/conflict-test/packages/test-package/vite.config.json +++ b/crates/vite_task_graph/tests/fixtures/conflict-test/packages/test-package/vite.config.json @@ -1,7 +1,8 @@ { "tasks": { "test": { - "command": "echo Testing", + "command": "A=B C=D vite run build", + "envs": ["C"], "cache": true, "dependsOn": ["@test/scope-a#b#c"] } diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index 5c7907f6..ccbc124b 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -15,7 +15,11 @@ 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 } diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index 5fc16032..4ff59e28 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -1,17 +1,247 @@ use std::{ collections::{BTreeMap, HashMap}, + env::{self, join_paths, split_paths}, + ffi::{OsStr, OsString}, + path::{self, PathBuf}, sync::Arc, }; +use sha2::{Digest as _, Sha256}; +use supports_color::{Stream, on}; +use vite_glob::GlobPatternSet; +use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_str::Str; +use vite_task_graph::config::EnvConfig; -/// Resolved environment variables for a command +/// 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 - /// Use BTreeMap to ensure stable order - pub fingerprinted_envs: BTreeMap>, + /// Environment variables that should be fingerprinted for this execution. + /// + /// Use `BTreeMap` to ensure stable order. + pub fingerprinted_envs: Arc>>, - /// Environment variables that should be passed through without being fingerprinted - pub pass_through_envs: HashMap>, + /// 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 }, + + #[error("Failed to join paths for PATH env")] + JoinPathsError { + #[source] + #[from] + join_paths_error: env::JoinPathsError, + }, +} + +fn prepend_paths( + envs: &mut HashMap, Arc>, + new_paths: &[impl AsRef], +) -> Result<(), env::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 = new_paths + .iter() + .map(|path| path.as_ref().to_absolute_path_buf().into_path_buf()) // Prepend new paths + .chain(existing_paths.filter( + // and remove duplicates + |path| new_paths.iter().all(|new_path| path != new_path.as_ref().as_path()), + )); + + *env_path = join_paths(paths)?.into(); + Ok(()) +} + +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). + /// + /// node_modules/.bin under package and workspace root will be added to PATH env. + /// + /// `package_path` can be `None` if the task is not associated with any package (e.g. synthetic tasks). + pub fn resolve( + all_envs: &mut Arc, Arc>>, + env_config: &EnvConfig, + package_path: Option<&AbsolutePath>, + workspace_root: &AbsolutePath, + ) -> Result { + // Collect all envs matching fingerpinted or pass-through envs in env_config + *all_envs = Arc::new({ + 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)), + ); + } + + // Prepend package/node_modules/.bin and workspace/node_modules/.bin to PATH + prepend_paths(&mut new_all_envs, &{ + let mut node_modules_bin_paths: Vec = vec![]; + if let Some(package_path) = package_path + && package_path != workspace_root + { + node_modules_bin_paths.push(package_path.join("node_modules").join(".bin")); + } + node_modules_bin_paths.push(workspace_root.join("node_modules").join(".bin")); + node_modules_bin_paths + })?; + 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: Arc::new(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", +]; diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs new file mode 100644 index 00000000..761acd96 --- /dev/null +++ b/crates/vite_task_plan/src/error.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use vite_path::AbsolutePath; +use vite_str::Str; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Failed to parse command '{subcommand}' in package at {package_path:?}")] + CallbackParseArgsError { + package_path: Arc, + subcommand: Str, + #[source] + error: anyhow::Error, + }, + + #[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, + ), +} diff --git a/crates/vite_task_plan/src/expand.rs b/crates/vite_task_plan/src/expand.rs index 268ba51d..6b16a267 100644 --- a/crates/vite_task_plan/src/expand.rs +++ b/crates/vite_task_plan/src/expand.rs @@ -1,6 +1,14 @@ +use core::task; +use std::{ffi::OsStr, sync::Arc}; + use petgraph::graph::DiGraph; +use vite_shell::try_parse_as_and_list; +use vite_task_graph::{IndexedTaskGraph, TaskGraph, TaskNodeIndex}; -use crate::{ExecutionGraphNode, ExpansionArgs, PlanContext}; +use crate::{ + ExecutionGraphNode, ExecutionItem, ExecutionItemKind, LeafExecutionItem, PlanContext, + QueryTasksSubcommand, Subcommand, error::Error, +}; /* @@ -51,16 +59,95 @@ impl ExpandedExecutionItem { */ -pub async fn expand_into_execution_graph( - expansion_args: ExpansionArgs, - context: PlanContext<'_>, -) -> DiGraph { - match expansion_args { - ExpansionArgs::QueryTaskGraph { query, plan_options } => { - let indexed_task_graph = - context.callbacks.load_task_graph().await.expect("Failed to load task graph"); +#[derive(Debug, thiserror::Error)] +pub enum ExecutionExpansionError { + #[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, + ), +} + +pub async fn resolve_task_to_execution_node( + indexed_task_graph: &IndexedTaskGraph, + task_node_index: TaskNodeIndex, + context: PlanContext, +) -> Result { + let task_node = &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(); + if let Some(parsed_subcommands) = try_parse_as_and_list(command_str) { + let mut items = Vec::::with_capacity(parsed_subcommands.len()); + for (and_item, add_item_span) in parsed_subcommands { + // Try to parse the args of an and_item to known vite subcommands like `run -r build` + let parsed_subcommand = context + .callbacks + .parse_args(&and_item.program, &and_item.args) + .map_err(|error| Error::CallbackParseArgsError { + package_path: Arc::clone( + indexed_task_graph.get_package_path(task_node.task_id.package_index), + ), + subcommand: (&command_str[add_item_span.clone()]).into(), + error, + })?; + + // Create a new context with additional envs from `ENV_VAR=value` items + let new_context = context + .with_envs(and_item.envs.iter().map(|(name, value)| (name.clone(), value.clone()))); + + let execution_item_kind: ExecutionItemKind = match parsed_subcommand { + Some(Subcommand::QueryTasks(query_tasks_subcommand)) => { + // Expand task query like `vite run -r build` + let execution_graph = + expand_into_execution_graph(query_tasks_subcommand, new_context).await?; + ExecutionItemKind::Expanded(execution_graph) + } + Some(Subcommand::Synthetic { name, extra_args }) => { + // Synthetic task, like `vite lint` + todo!() + } + None => { + todo!() + // Normal 3rd party tool command (like `tsc --noEmit`) + // ExecutionItemKind::Leaf(LeafExecutionItem { + // resolved_envs: todo!(), + // cwd: Arc::clone(&new_context.cwd), + // command_kind: todo!(), + // }) + } + }; + items.push(ExecutionItem { command_span: add_item_span, kind: execution_item_kind }); } - ExpansionArgs::Synthetic { name, extra_args } => {} + } else { + } + + todo!() +} + +/// Expand the parsed command arguments (like `-r build`) into an execution graph. +pub async fn expand_into_execution_graph( + query_tasks_subcommand: QueryTasksSubcommand, + context: PlanContext, +) -> Result, Error> { + let indexed_task_graph = context.callbacks.load_task_graph().await?; + + // Query matching tasks from the task graph + let task_node_index_graph = indexed_task_graph.query_tasks(query_tasks_subcommand.query)?; + + let task_graph = indexed_task_graph.task_graph(); + for (from_task_index, to_task_index, ()) in task_node_index_graph.all_edges() { + let from_task = &task_graph[from_task_index]; + let to_task = &task_graph[to_task_index]; } + + // Subcommand::Synthetic { name, extra_args } => {} todo!() } diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 543b6929..b64136ee 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -1,38 +1,50 @@ mod envs; +mod error; mod expand; mod leaf; -use std::{collections::HashMap, fmt::Debug, sync::Arc}; +use std::{ + collections::{BTreeMap, HashMap}, + ffi::OsStr, + fmt::Debug, + hash::Hash, + ops::Range, + sync::Arc, +}; use envs::ResolvedEnvs; use futures_core::future::BoxFuture; use futures_util::FutureExt; use petgraph::graph::DiGraph; use vite_path::AbsolutePath; -use vite_shell::ParsedScript; +use vite_shell::TaskParsedCommand; use vite_str::Str; use vite_task_graph::{IndexedTaskGraph, TaskNode, TaskNodeIndex, query::TaskQuery}; +/* /// Where an execution originates from #[derive(Debug)] pub enum ExecutionOrigin { /// the execution originates from the task graph (defined in `package.json` or `vite.config.*`) - TaskGraph { - /// the task index in the task graph - task_index: TaskNodeIndex, - /// the index of the subcommand in parsed script `subcommand0 && subcommand1 ...`. - /// - /// 0 if the script is not parsable. - subcommand: usize, - }, + /// + /// The precise location of this execution in the task graph can be inferred by + /// `ExecutionGraphNode.task_index` and index of `ExecutionItem` in `ExecutionGraphNode.items`. + TaskGraph, /// the command originates from an synthetic command, like `oxlint ...` synthesized from `vite lint` - Synthetic { - /// the name of the synthetic command. - /// This is going to be part of associated task name in cache, so that a second `vite lint` can - /// report cache miss compared to the first one. - name: Str, - }, + Synthetic +} + */ + +/// Resolved cache configuration for a leaf execution. +#[derive(Debug)] +pub struct ResolvedCacheConfig { + /// Environment variables that should be fingerprinted for this execution. + pub fingerprinted_envs: Arc>>, + + /// 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]>, } /// A resolved leaf execution. @@ -40,17 +52,21 @@ pub enum ExecutionOrigin { /// like resolved environment variables, current working directory, and additional args from cli. #[derive(Debug)] pub struct LeafExecutionItem { - /// Where this resolved command originates from - pub origin: ExecutionOrigin, + /* + /// Where this resolved command originates from + pub origin: ExecutionOrigin, + */ + /// Resolved cache configuration for this execution. `None` means caching is disabled. + pub resolved_cache_config: Option, - /// Environment variables to set for the command - pub resolved_envs: ResolvedEnvs, + /// Environment variables to set for the command, including both fingerprinted and pass-through envs. + pub all_envs: Arc>>, /// Current working directory pub cwd: Arc, /// parsed program with args or shell script - pub kind: LeafExecutionKind, + pub command_kind: LeafCommandKind, } pub enum LeafTaskResolutionError {} @@ -58,7 +74,7 @@ pub enum LeafTaskResolutionError {} impl LeafExecutionItem { pub fn resolve_from_task( task_node: &TaskNode, - context: PlanContext<'_>, + context: PlanContext, ) -> Result { todo!() } @@ -66,7 +82,7 @@ impl LeafExecutionItem { /// The kind of a leaf execution #[derive(Debug)] -pub enum LeafExecutionKind { +pub enum LeafCommandKind { /// A program with args to be executed directly Program { program: Str, args: Arc<[Str]> }, /// A script to be executed by os shell @@ -85,19 +101,14 @@ pub struct ExecutionGraphNode { pub items: Vec, } -#[derive(Debug)] -pub enum ExecutionItemScript { - Parsed(ParsedScript), - ShellScript(Str), -} - /// An execution item, either expanded from a known vite subcommand, or a leaf execution. #[derive(Debug)] pub struct ExecutionItem { - /// The script that this execution item is resolved from. + /// The range of the task command that this execution item is resolved from. /// - /// This field is for displaying purpose only. The actual execution info is in `kind`. - pub script: ExecutionItemScript, + /// This field is for displaying purpose only. + /// The actual execution info (if this is leaf) is in `LeafExecutionItem.command_kind`. + pub command_span: Range, /// The kind of this execution item pub kind: ExecutionItemKind, @@ -115,37 +126,73 @@ pub enum ExecutionItemKind { /// Callbackes needed during planning. /// See each method for details. pub trait PlanCallbacks: Debug { - fn load_task_graph<'s>( - &'s mut self, - ) -> BoxFuture<'s, Result<&'s IndexedTaskGraph, vite_task_graph::TaskGraphLoadError>>; + fn load_task_graph<'me>( + &'me self, + ) -> BoxFuture<'me, Result<&'me IndexedTaskGraph, vite_task_graph::TaskGraphLoadError>>; - /// This is called for every parsable command in order to determine how to expand it. + /// 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 + /// `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::QueryTaskGraph)`, 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 expanded as a `ExpandedExecution` with a task graph containing the synthetic task. - fn parse_into_expansion_args( - &mut self, - program: &str, - args: &[Str], - ) -> anyhow::Result>; + /// - 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 `LeafExecution` with the synthetic task. + fn parse_args(&self, program: &str, args: &[Str]) -> anyhow::Result>; } /// The context for planning an execution from a task. #[derive(Debug)] -pub struct PlanContext<'a> { +pub struct PlanContext { pub cwd: Arc, - pub envs: HashMap>, - pub callbacks: &'a mut dyn PlanCallbacks, + pub envs: Arc, Arc>>, + pub callbacks: Arc, +} + +impl PlanContext { + /// Create a new context with additional environment variables. + pub fn with_envs( + &self, + envs: impl Iterator, impl AsRef)>, + ) -> PlanContext { + let mut new_envs: Option, Arc>> = None; + for (key, value) in envs { + // Clone on write + new_envs + .get_or_insert_default() + .insert(Arc::from(key.as_ref()), Arc::from(value.as_ref())); + } + PlanContext { + cwd: Arc::clone(&self.cwd), + envs: if let Some(new_envs) = new_envs { + Arc::new(new_envs) + } else { + Arc::clone(&self.envs) + }, + callbacks: Arc::clone(&self.callbacks), + } + } +} + +/// The command arguments indicating to run tasks queried from the task graph. +/// For example: `vite run -r build -- arg1 arg2` +#[derive(Debug)] +pub struct QueryTasksSubcommand { + /// 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 parsed cli arguments for expansion. +/// The parsed command arguments. #[derive(Debug)] -pub enum ExpansionArgs { - QueryTaskGraph { query: TaskQuery, plan_options: PlanOptions }, +pub enum Subcommand { + /// The args indicate to run tasks queried from the task graph, like `vite run -r build -- arg1 arg2`. + QueryTasks(QueryTasksSubcommand), + /// The args indicate to run a synthetic task, like `vite lint`. Synthetic { name: Str, extra_args: Arc<[Str]> }, } @@ -168,5 +215,5 @@ impl ExecutionPlan { &self.root_node } - pub async fn plan(&self, args: ExpansionArgs, context: PlanContext<'_>) {} + pub async fn plan(&self, args: Subcommand, context: PlanContext) {} } From 13b04ea5df0ae3a34e34c384363d752dcaded57a Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 12 Dec 2025 17:34:40 +0800 Subject: [PATCH 06/26] test(vite_task_plan): add tests for ResolvedEnvs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate tests from vite_task/execute.rs and add new tests for envs.rs: Migrated tests: - test_force_color_auto_detection - test_task_envs_stable_ordering (Unix) - test_unix_env_case_sensitive (Unix) - test_windows_env_case_insensitive (Windows) - test_windows_path_case_insensitive_* (Windows) - test_unix_path_case_sensitive (Unix) New tests for changed/new logic: - test_btreemap_stable_fingerprint (BTreeMap ordering) - test_pass_through_envs_names_stored - test_package_path_* (path deduplication logic) - test_all_envs_mutated_after_resolve - test_error_env_value_not_valid_unicode - test_prepend_paths_removes_duplicates - test_sensitive_env_hashing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crates/vite_task_plan/src/envs.rs | 584 ++++++++++++++++++++++++++++++ 1 file changed, 584 insertions(+) diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index 4ff59e28..e8e173ae 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -245,3 +245,587 @@ const SENSITIVE_PATTERNS: &[&str] = &[ "SECRET", "TOKEN", ]; + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn create_test_envs(pairs: Vec<(&str, &str)>) -> Arc, Arc>> { + Arc::new( + 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() { + let workspace_root = if cfg!(windows) { + AbsolutePath::new("C:\\workspace").unwrap() + } else { + AbsolutePath::new("/workspace").unwrap() + }; + + // 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, None, workspace_root).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, None, workspace_root).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, None, workspace_root).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() { + let workspace_root = AbsolutePath::new("/workspace").unwrap(); + + // 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, None, workspace_root).unwrap(); + let result2 = + ResolvedEnvs::resolve(&mut all_envs2, &env_config, None, workspace_root).unwrap(); + let result3 = + ResolvedEnvs::resolve(&mut all_envs3, &env_config, None, workspace_root).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 + let workspace_root = AbsolutePath::new("/workspace").unwrap(); + + // 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, None, workspace_root).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 workspace_root = AbsolutePath::new("C:\\workspace").unwrap(); + + 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, None, workspace_root).unwrap(); + let result2 = + ResolvedEnvs::resolve(&mut all_envs2, &env_config, None, workspace_root).unwrap(); + let result3 = + ResolvedEnvs::resolve(&mut all_envs3, &env_config, None, workspace_root).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")) + ); + } + + #[test] + #[cfg(windows)] + fn test_windows_path_case_insensitive_mixed_case() { + let workspace_root = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); + + let env_config = create_env_config(&[], &["Path", "OTHER_VAR"]); + + let mut all_envs = + create_test_envs(vec![("Path", "C:\\existing\\path"), ("OTHER_VAR", "value")]); + + let _result = + ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + + // Verify that the original "Path" casing is preserved, not "PATH" + assert!(all_envs.contains_key(OsStr::new("Path"))); + assert!(!all_envs.contains_key(OsStr::new("PATH"))); + + // Verify the PATH value has node_modules/.bin prepended + let path_value = all_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<_> = all_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 workspace_root = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); + + let env_config = create_env_config(&[], &["PATH", "OTHER_VAR"]); + + let mut all_envs = + create_test_envs(vec![("PATH", "C:\\existing\\path"), ("OTHER_VAR", "value")]); + + let _result = + ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + + // Verify the PATH value has node_modules/.bin prepended + let path_value = all_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 workspace_root = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); + + let env_config = create_env_config(&[], &["OTHER_VAR"]); + + let mut all_envs = create_test_envs(vec![("OTHER_VAR", "value")]); + + let _result = + ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + + // Verify PATH was created with only node_modules/.bin + let path_value = all_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 workspace_root = AbsolutePath::new("/workspace/packages/app").unwrap(); + + let env_config = create_env_config(&[], &["PATH", "OTHER_VAR"]); + + let mut all_envs = + create_test_envs(vec![("PATH", "/existing/path"), ("OTHER_VAR", "value")]); + + let _result = + ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + + // Verify "PATH" exists and the complete value has node_modules/.bin prepended + let path_value = all_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!(!all_envs.contains_key(OsStr::new("Path"))); + assert!(!all_envs.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 workspace_root = if cfg!(windows) { + AbsolutePath::new("C:\\workspace").unwrap() + } else { + AbsolutePath::new("/workspace").unwrap() + }; + + 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, None, workspace_root).unwrap(); + let result2 = + ResolvedEnvs::resolve(&mut all_envs2, &env_config, None, workspace_root).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 workspace_root = if cfg!(windows) { + AbsolutePath::new("C:\\workspace").unwrap() + } else { + AbsolutePath::new("/workspace").unwrap() + }; + + 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, None, workspace_root).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] + #[cfg(unix)] + fn test_package_path_equals_workspace_root() { + // When package_path == workspace_root, only one node_modules/.bin should be added + let workspace_root = AbsolutePath::new("/workspace").unwrap(); + let package_path = AbsolutePath::new("/workspace").unwrap(); + + let env_config = create_env_config(&[], &["PATH"]); + + let mut all_envs = create_test_envs(vec![("PATH", "/existing/path")]); + + let _result = + ResolvedEnvs::resolve(&mut all_envs, &env_config, Some(package_path), workspace_root) + .unwrap(); + + let path_value = all_envs.get(OsStr::new("PATH")).unwrap(); + let path_str = path_value.to_str().unwrap(); + + // Should only contain one node_modules/.bin entry (workspace root) + let node_modules_count = path_str.matches("node_modules/.bin").count(); + assert_eq!( + node_modules_count, 1, + "Should have exactly one node_modules/.bin when package_path == workspace_root" + ); + } + + #[test] + #[cfg(unix)] + fn test_package_path_different_from_workspace_root() { + // When paths differ, both node_modules/.bin paths should be added + let workspace_root = AbsolutePath::new("/workspace").unwrap(); + let package_path = AbsolutePath::new("/workspace/packages/app").unwrap(); + + let env_config = create_env_config(&[], &["PATH"]); + + let mut all_envs = create_test_envs(vec![("PATH", "/existing/path")]); + + let _result = + ResolvedEnvs::resolve(&mut all_envs, &env_config, Some(package_path), workspace_root) + .unwrap(); + + let path_value = all_envs.get(OsStr::new("PATH")).unwrap(); + let path_str = path_value.to_str().unwrap(); + + // Should contain two node_modules/.bin entries + let node_modules_count = path_str.matches("node_modules/.bin").count(); + assert_eq!(node_modules_count, 2, "Should have two node_modules/.bin when paths differ"); + + // Package path should come before workspace path + let package_pos = path_str.find("/workspace/packages/app/node_modules/.bin"); + let workspace_pos = path_str.find("/workspace/node_modules/.bin"); + assert!(package_pos.is_some()); + assert!(workspace_pos.is_some()); + assert!(package_pos.unwrap() < workspace_pos.unwrap(), "Package path should come first"); + } + + #[test] + #[cfg(unix)] + fn test_package_path_none() { + // When package_path is None, only workspace_root/node_modules/.bin should be added + let workspace_root = AbsolutePath::new("/workspace").unwrap(); + + let env_config = create_env_config(&[], &["PATH"]); + + let mut all_envs = create_test_envs(vec![("PATH", "/existing/path")]); + + let _result = + ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + + let path_value = all_envs.get(OsStr::new("PATH")).unwrap(); + let path_str = path_value.to_str().unwrap(); + + // Should contain only workspace root's node_modules/.bin + assert!(path_str.contains("/workspace/node_modules/.bin")); + let node_modules_count = path_str.matches("node_modules/.bin").count(); + assert_eq!(node_modules_count, 1); + } + + #[test] + fn test_all_envs_mutated_after_resolve() { + let workspace_root = if cfg!(windows) { + AbsolutePath::new("C:\\workspace").unwrap() + } else { + AbsolutePath::new("/workspace").unwrap() + }; + + // 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, None, workspace_root).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 workspace_root = AbsolutePath::new("/workspace").unwrap(); + + 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: Arc, Arc>> = Arc::new( + [(Arc::from(OsStr::new("INVALID_UTF8")), Arc::from(invalid_utf8))] + .into_iter() + .collect(), + ); + + let result = ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root); + + 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] + #[cfg(unix)] + fn test_prepend_paths_removes_duplicates() { + let workspace_root = AbsolutePath::new("/workspace").unwrap(); + + let env_config = create_env_config(&[], &["PATH"]); + + // PATH already contains the node_modules/.bin path + let mut all_envs = + create_test_envs(vec![("PATH", "/workspace/node_modules/.bin:/other/path")]); + + let _result = + ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + + let path_value = all_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"); + } + + #[test] + fn test_sensitive_env_hashing() { + let workspace_root = if cfg!(windows) { + AbsolutePath::new("C:\\workspace").unwrap() + } else { + AbsolutePath::new("/workspace").unwrap() + }; + + // 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, None, workspace_root).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"); + } +} From 797a725af346287f7ff7d03d2841ace694a8711b Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 13 Dec 2025 23:48:31 +0800 Subject: [PATCH 07/26] wip --- Cargo.lock | 11 ++-- Cargo.toml | 1 + crates/vite_task_plan/Cargo.toml | 1 + crates/vite_task_plan/src/context.rs | 78 ++++++++++++++++++++++++++++ crates/vite_task_plan/src/error.rs | 17 ++++-- crates/vite_task_plan/src/expand.rs | 65 +++++++++++------------ crates/vite_task_plan/src/lib.rs | 45 ++-------------- 7 files changed, 135 insertions(+), 83 deletions(-) create mode 100644 crates/vite_task_plan/src/context.rs diff --git a/Cargo.lock b/Cargo.lock index 908bcf4b..a98a084a 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]] @@ -3235,6 +3235,7 @@ dependencies = [ "anyhow", "futures-core", "futures-util", + "indexmap", "petgraph", "sha2", "supports-color", diff --git a/Cargo.toml b/Cargo.toml index f0b19e44..cc247e87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ fspy_test_utils = { path = "crates/fspy_test_utils" } futures = "0.3.31" futures-core = "0.3.31" futures-util = "0.3.31" +indexmap = "2.12.1" insta = "1.44.3" itertools = "0.14.0" libc = "0.2.172" diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index ccbc124b..e04b0a2f 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -14,6 +14,7 @@ workspace = true anyhow = { workspace = true } futures-core = { workspace = true } futures-util = { workspace = true } +indexmap = { workspace = true } petgraph = { workspace = true } sha2 = { workspace = true } supports-color = { workspace = true } diff --git a/crates/vite_task_plan/src/context.rs b/crates/vite_task_plan/src/context.rs new file mode 100644 index 00000000..6b371d91 --- /dev/null +++ b/crates/vite_task_plan/src/context.rs @@ -0,0 +1,78 @@ +use std::{collections::HashMap, ffi::OsStr, ops::Range, sync::Arc}; + +use indexmap::IndexSet; +use vite_path::AbsolutePath; +use vite_task_graph::TaskNodeIndex; + +use crate::PlanCallbacks; + +/// The context for planning an execution from a task. +#[derive(Debug)] +pub struct PlanContext<'a> { + pub cwd: &'a Arc, + pub envs: &'a HashMap, Arc>, + pub callbacks: &'a mut (dyn PlanCallbacks + 'a), + pub task_call_stack: &'a mut IndexSet, +} + +// #[derive(Debug, thiserror::Error)] +// #[error("Cycle detected")] +// pub struct PackageCycleError(); + +impl<'a> PlanContext<'a> { + pub fn with_envs(&self, envs: impl Iterator, impl AsRef)>) { + let mut new_envs: Option, Arc>> = None; + for (key, value) in envs { + // Clone on write + new_envs + .get_or_insert_with(|| self.envs.clone()) + .insert(Arc::from(key.as_ref()), Arc::from(value.as_ref())); + } + } +} +// pub fn enter_package(&mut self, package_path: Arc) -> Result, PackageCycleError> { +// Ok(PlanContext { +// cwd: package_path, +// envs: Arc::clone(&self.envs), +// callbacks: self.callbacks, +// stack: self.stack, +// }) +// } + +// /// Create a new context with new frame. +// /// +// /// Returns `None` if the new frame already exists in the stack (to prevent infinite recursion). +// pub fn with_new_frame( +// &mut self, +// new_frame: PlanStackFrame, +// envs: impl Iterator, impl AsRef)>, +// cwd: Arc, +// f: impl FnOnce(PlanContext<'_>) -> R, +// ) -> Option { +// // IndexSet::insert returns `false` and doesn't touch the set if the item already exists. +// if !self.stack.insert(new_frame) { +// return None; +// } +// // Merge envs +// let mut new_envs: Option, Arc>> = None; +// for (key, value) in envs { +// // Clone on write +// new_envs +// .get_or_insert_with(|| self.envs.as_ref().clone()) +// .insert(Arc::from(key.as_ref()), Arc::from(value.as_ref())); +// } + +// let ret = f(PlanContext { +// cwd, +// envs: if let Some(new_envs) = new_envs { +// Arc::new(new_envs) +// } else { +// Arc::clone(&self.envs) +// }, +// callbacks: self.callbacks, +// stack: self.stack, +// }); +// self.stack.pop().expect("stack pop should succeed"); +// Some(ret) +// } +// } diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 761acd96..963ea3c0 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -3,16 +3,19 @@ use std::sync::Arc; use vite_path::AbsolutePath; use vite_str::Str; +/// Errors that can occur when planning a specific execution from a task . #[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("Failed to parse command '{subcommand}' in package at {package_path:?}")] +pub enum TaskPlanError { + #[error("Failed to parse command '{subcommand}'")] CallbackParseArgsError { - package_path: Arc, subcommand: Str, #[source] error: anyhow::Error, }, +} +#[derive(Debug, thiserror::Error)] +pub enum Error { #[error("Failed to load task graph")] TaskGraphLoadError( #[source] @@ -26,4 +29,12 @@ pub enum Error { #[from] vite_task_graph::query::TaskQueryError, ), + + #[error("Failed to plan execution for task '{package_name}#{task_name}'")] + TaskPlanError { + package_name: Str, + task_name: Str, + #[source] + task_plan_error: TaskPlanError, + }, } diff --git a/crates/vite_task_plan/src/expand.rs b/crates/vite_task_plan/src/expand.rs index 6b16a267..e8bfe917 100644 --- a/crates/vite_task_plan/src/expand.rs +++ b/crates/vite_task_plan/src/expand.rs @@ -1,5 +1,5 @@ use core::task; -use std::{ffi::OsStr, sync::Arc}; +use std::{ffi::OsStr, sync::Arc, task::Context}; use petgraph::graph::DiGraph; use vite_shell::try_parse_as_and_list; @@ -7,7 +7,9 @@ use vite_task_graph::{IndexedTaskGraph, TaskGraph, TaskNodeIndex}; use crate::{ ExecutionGraphNode, ExecutionItem, ExecutionItemKind, LeafExecutionItem, PlanContext, - QueryTasksSubcommand, Subcommand, error::Error, + QueryTasksSubcommand, ResolvedCacheConfig, Subcommand, + envs::ResolvedEnvs, + error::{Error, TaskPlanError}, }; /* @@ -59,29 +61,17 @@ impl ExpandedExecutionItem { */ -#[derive(Debug, thiserror::Error)] -pub enum ExecutionExpansionError { - #[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, - ), -} - pub async fn resolve_task_to_execution_node( indexed_task_graph: &IndexedTaskGraph, task_node_index: TaskNodeIndex, - context: PlanContext, -) -> Result { + context: PlanContext<'_>, +) -> Result { let task_node = &indexed_task_graph.task_graph()[task_node_index]; + if !context.task_call_stack.insert(task_node_index) { + todo!("report cycle error"); + } + // TODO: variable expansion (https://crates.io/crates/shellexpand) BEFORE parsing let command_str = task_node.resolved_config.command.as_str(); if let Some(parsed_subcommands) = try_parse_as_and_list(command_str) { @@ -91,37 +81,42 @@ pub async fn resolve_task_to_execution_node( let parsed_subcommand = context .callbacks .parse_args(&and_item.program, &and_item.args) - .map_err(|error| Error::CallbackParseArgsError { - package_path: Arc::clone( - indexed_task_graph.get_package_path(task_node.task_id.package_index), - ), + .map_err(|error| TaskPlanError::CallbackParseArgsError { subcommand: (&command_str[add_item_span.clone()]).into(), error, })?; - // Create a new context with additional envs from `ENV_VAR=value` items - let new_context = context - .with_envs(and_item.envs.iter().map(|(name, value)| (name.clone(), value.clone()))); - let execution_item_kind: ExecutionItemKind = match parsed_subcommand { + // Expand task query like `vite run -r build` Some(Subcommand::QueryTasks(query_tasks_subcommand)) => { - // Expand task query like `vite run -r build` - let execution_graph = - expand_into_execution_graph(query_tasks_subcommand, new_context).await?; - ExecutionItemKind::Expanded(execution_graph) + // Add envs from the prefix to the new context + + // let execution_graph = + // expand_into_execution_graph(query_tasks_subcommand, new_context).await?; + // ExecutionItemKind::Expanded(execution_graph) + todo!() } Some(Subcommand::Synthetic { name, extra_args }) => { // Synthetic task, like `vite lint` todo!() } None => { - todo!() // Normal 3rd party tool command (like `tsc --noEmit`) // ExecutionItemKind::Leaf(LeafExecutionItem { - // resolved_envs: todo!(), + // resolved_cache_config: task_node.resolved_config.cache_config.map( + // |cache_config| ResolvedCacheConfig { + // resolved_envs: ResolvedEnvs::resolve( + // todo!(), + // todo!(), + // todo!(), + // todo!(), + // )?, + // }, + // ), // cwd: Arc::clone(&new_context.cwd), // command_kind: todo!(), // }) + todo!() } }; items.push(ExecutionItem { command_span: add_item_span, kind: execution_item_kind }); @@ -135,7 +130,7 @@ pub async fn resolve_task_to_execution_node( /// Expand the parsed command arguments (like `-r build`) into an execution graph. pub async fn expand_into_execution_graph( query_tasks_subcommand: QueryTasksSubcommand, - context: PlanContext, + context: PlanContext<'_>, ) -> Result, Error> { let indexed_task_graph = context.callbacks.load_task_graph().await?; diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index b64136ee..63cfd8dc 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -1,3 +1,4 @@ +mod context; mod envs; mod error; mod expand; @@ -12,6 +13,7 @@ use std::{ sync::Arc, }; +use context::PlanContext; use envs::ResolvedEnvs; use futures_core::future::BoxFuture; use futures_util::FutureExt; @@ -39,12 +41,8 @@ pub enum ExecutionOrigin { /// Resolved cache configuration for a leaf execution. #[derive(Debug)] pub struct ResolvedCacheConfig { - /// Environment variables that should be fingerprinted for this execution. - pub fingerprinted_envs: Arc>>, - - /// 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]>, + /// Environment variables that are used for fingerprinting the cache. + pub resolved_envs: ResolvedEnvs, } /// A resolved leaf execution. @@ -141,39 +139,6 @@ pub trait PlanCallbacks: Debug { fn parse_args(&self, program: &str, args: &[Str]) -> anyhow::Result>; } -/// The context for planning an execution from a task. -#[derive(Debug)] -pub struct PlanContext { - pub cwd: Arc, - pub envs: Arc, Arc>>, - pub callbacks: Arc, -} - -impl PlanContext { - /// Create a new context with additional environment variables. - pub fn with_envs( - &self, - envs: impl Iterator, impl AsRef)>, - ) -> PlanContext { - let mut new_envs: Option, Arc>> = None; - for (key, value) in envs { - // Clone on write - new_envs - .get_or_insert_default() - .insert(Arc::from(key.as_ref()), Arc::from(value.as_ref())); - } - PlanContext { - cwd: Arc::clone(&self.cwd), - envs: if let Some(new_envs) = new_envs { - Arc::new(new_envs) - } else { - Arc::clone(&self.envs) - }, - callbacks: Arc::clone(&self.callbacks), - } - } -} - /// The command arguments indicating to run tasks queried from the task graph. /// For example: `vite run -r build -- arg1 arg2` #[derive(Debug)] @@ -215,5 +180,5 @@ impl ExecutionPlan { &self.root_node } - pub async fn plan(&self, args: Subcommand, context: PlanContext) {} + pub async fn plan(&self, args: Subcommand, context: PlanContext<'_>) {} } From e13076f597f71885676e8e89a9bc4b63e3428be3 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 14 Dec 2025 20:39:31 +0800 Subject: [PATCH 08/26] wip --- crates/vite_task_graph/src/display.rs | 41 ++++++ crates/vite_task_graph/src/lib.rs | 2 + crates/vite_task_graph/src/package_graph.rs | 1 + crates/vite_task_plan/src/context.rs | 147 +++++++++++++++++--- crates/vite_task_plan/src/envs.rs | 50 ------- crates/vite_task_plan/src/error.rs | 68 ++++++--- crates/vite_task_plan/src/expand.rs | 63 ++++++--- crates/vite_task_plan/src/lib.rs | 5 +- crates/vite_task_plan/src/path_env.rs | 44 ++++++ 9 files changed, 311 insertions(+), 110 deletions(-) create mode 100644 crates/vite_task_graph/src/display.rs create mode 100644 crates/vite_task_plan/src/path_env.rs diff --git a/crates/vite_task_graph/src/display.rs b/crates/vite_task_graph/src/display.rs new file mode 100644 index 00000000..612b089e --- /dev/null +++ b/crates/vite_task_graph/src/display.rs @@ -0,0 +1,41 @@ +//! 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 TaskDispay { + pub package_name: Str, + pub task_name: Str, + pub package_path: Arc, +} + +impl Display for TaskDispay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}#{} ({})", + self.package_name, + self.task_name, + self.package_path.as_path().display() + ) + } +} + +impl IndexedTaskGraph { + /// Get human-readable display for a task node. + pub fn display_task(&self, task_index: TaskNodeIndex) -> TaskDispay { + let task_node = &self.task_graph()[task_index]; + let package = &self.indexed_package_graph.package_graph()[task_node.task_id.package_index]; + TaskDispay { + 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 3d64090f..600aeca7 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; @@ -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, 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_plan/src/context.rs b/crates/vite_task_plan/src/context.rs index 6b371d91..c915ef5f 100644 --- a/crates/vite_task_plan/src/context.rs +++ b/crates/vite_task_plan/src/context.rs @@ -1,32 +1,143 @@ -use std::{collections::HashMap, ffi::OsStr, ops::Range, sync::Arc}; +use std::{ + collections::HashMap, env::JoinPathsError, ffi::OsStr, fmt::Display, ops::Range, sync::Arc, +}; -use indexmap::IndexSet; use vite_path::AbsolutePath; -use vite_task_graph::TaskNodeIndex; +use vite_task_graph::{IndexedTaskGraph, TaskNodeIndex, display::TaskDispay}; -use crate::PlanCallbacks; +use crate::{PlanCallbacks, path_env::prepend_path_env}; + +#[derive(Debug, thiserror::Error)] +#[error( + "Detected a cycle in task call stack, from the {0}th frame to the end", cycle_start + 1 +)] +pub struct TaskCycleError { + /// The index in `task_call_stack` where the cycle starts + /// + /// The cycle ends at the end of `task_call_stack`. + cycle_start: usize, +} /// The context for planning an execution from a task. #[derive(Debug)] pub struct PlanContext<'a> { - pub cwd: &'a Arc, - pub envs: &'a HashMap, Arc>, - pub callbacks: &'a mut (dyn PlanCallbacks + 'a), - pub task_call_stack: &'a mut IndexSet, + /// The current working directory. + cwd: Arc, + + /// The environment variables for the current execution context. + envs: HashMap, Arc>, + + /// The callbacks for loading task graphs and parsing commands. + callbacks: &'a mut (dyn PlanCallbacks + 'a), + + /// The current call stack of task index nodes being planned. + task_call_stack: Vec<(TaskNodeIndex, Range)>, + + indexed_task_graph: &'a IndexedTaskGraph, +} + +/// A human-readable frame in the task call stack. +#[derive(Debug, Clone)] +pub struct TaskCallStackFrameDisplay { + pub task_display: TaskDispay, + 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(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(()) + } } -// #[derive(Debug, thiserror::Error)] -// #[error("Cycle detected")] -// pub struct PackageCycleError(); +pub struct TaskCallFrame { + pub task_index: TaskNodeIndex, +} impl<'a> PlanContext<'a> { - pub fn with_envs(&self, envs: impl Iterator, impl AsRef)>) { - let mut new_envs: Option, Arc>> = None; - for (key, value) in envs { - // Clone on write - new_envs - .get_or_insert_with(|| self.envs.clone()) - .insert(Arc::from(key.as_ref()), Arc::from(value.as_ref())); + 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 cycle in the call stack. + pub fn check_cycle(&self, task_node_index: TaskNodeIndex) -> Result<(), TaskCycleError> { + if let Some(cycle_start) = + self.task_call_stack.iter().position(|(idx, _)| *idx == task_node_index) + { + return Err(TaskCycleError { cycle_start }); + } + Ok(()) + } + + pub fn indexed_task_graph(&self) -> &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 index e8e173ae..97dcb9fa 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -49,44 +49,6 @@ pub enum ResolveEnvError { join_paths_error: env::JoinPathsError, }, } - -fn prepend_paths( - envs: &mut HashMap, Arc>, - new_paths: &[impl AsRef], -) -> Result<(), env::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 = new_paths - .iter() - .map(|path| path.as_ref().to_absolute_path_buf().into_path_buf()) // Prepend new paths - .chain(existing_paths.filter( - // and remove duplicates - |path| new_paths.iter().all(|new_path| path != new_path.as_ref().as_path()), - )); - - *env_path = join_paths(paths)?.into(); - Ok(()) -} - impl ResolvedEnvs { /// Resolves from all available envs and env config. /// @@ -135,18 +97,6 @@ impl ResolvedEnvs { Arc::::from(OsStr::new(force_color_value)), ); } - - // Prepend package/node_modules/.bin and workspace/node_modules/.bin to PATH - prepend_paths(&mut new_all_envs, &{ - let mut node_modules_bin_paths: Vec = vec![]; - if let Some(package_path) = package_path - && package_path != workspace_root - { - node_modules_bin_paths.push(package_path.join("node_modules").join(".bin")); - } - node_modules_bin_paths.push(workspace_root.join("node_modules").join(".bin")); - node_modules_bin_paths - })?; new_all_envs }); diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 963ea3c0..b1613dbd 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -1,21 +1,16 @@ -use std::sync::Arc; +use std::{env::JoinPathsError, sync::Arc}; use vite_path::AbsolutePath; use vite_str::Str; +use vite_task_graph::{TaskNodeIndex, display::TaskDispay}; -/// Errors that can occur when planning a specific execution from a task . -#[derive(Debug, thiserror::Error)] -pub enum TaskPlanError { - #[error("Failed to parse command '{subcommand}'")] - CallbackParseArgsError { - subcommand: Str, - #[source] - error: anyhow::Error, - }, -} +use crate::context::{ + PlanContext, TaskCallStackDisplay, TaskCallStackFrameDisplay, TaskCycleError, +}; +/// Errors that can occur when planning a specific execution from a task . #[derive(Debug, thiserror::Error)] -pub enum Error { +pub enum TaskPlanErrorKind { #[error("Failed to load task graph")] TaskGraphLoadError( #[source] @@ -30,11 +25,50 @@ pub enum Error { vite_task_graph::query::TaskQueryError, ), - #[error("Failed to plan execution for task '{package_name}#{task_name}'")] - TaskPlanError { - package_name: Str, - task_name: Str, + #[error(transparent)] + TaskCycleDetected(#[from] TaskCycleError), + + #[error("Failed to parse command")] + CallbackParseArgsError { #[source] - task_plan_error: TaskPlanError, + 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: TaskDispay, + #[source] + join_paths_error: JoinPathsError, + }, +} + +#[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 trait TaskPlanErrorKindResultExt { + type Ok; + fn with_task_call_stack(self, context: &PlanContext<'_>) -> Result; +} + +impl TaskPlanErrorKindResultExt for Result { + type Ok = T; + + fn with_task_call_stack(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 }) + } + } + } } diff --git a/crates/vite_task_plan/src/expand.rs b/crates/vite_task_plan/src/expand.rs index e8bfe917..5af28288 100644 --- a/crates/vite_task_plan/src/expand.rs +++ b/crates/vite_task_plan/src/expand.rs @@ -1,5 +1,5 @@ use core::task; -use std::{ffi::OsStr, sync::Arc, task::Context}; +use std::{collections::HashMap, ffi::OsStr, sync::Arc, task::Context}; use petgraph::graph::DiGraph; use vite_shell::try_parse_as_and_list; @@ -9,7 +9,7 @@ use crate::{ ExecutionGraphNode, ExecutionItem, ExecutionItemKind, LeafExecutionItem, PlanContext, QueryTasksSubcommand, ResolvedCacheConfig, Subcommand, envs::ResolvedEnvs, - error::{Error, TaskPlanError}, + error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, }; /* @@ -64,37 +64,55 @@ impl ExpandedExecutionItem { pub async fn resolve_task_to_execution_node( indexed_task_graph: &IndexedTaskGraph, task_node_index: TaskNodeIndex, - context: PlanContext<'_>, -) -> Result { + mut context: PlanContext<'_>, +) -> Result { let task_node = &indexed_task_graph.task_graph()[task_node_index]; - if !context.task_call_stack.insert(task_node_index) { - todo!("report cycle error"); - } + // Check for cycles in the task call stack. + context + .check_cycle(task_node_index) + .map_err(TaskPlanErrorKind::TaskCycleDetected) + .with_task_call_stack(&context)?; + + // Prepend {package_path}/node_modules/.bin to PATH + context + .prepend_path( + &indexed_task_graph + .get_package_path(task_node.task_id.package_index) + .join("node_modules") + .join(".bin"), + ) + .map_err(|join_paths_error| TaskPlanErrorKind::AddNodeModulesBinPathError { + task_display: context.indexed_task_graph().display_task(task_node_index), + join_paths_error, + }) + .with_task_call_stack(&context)?; // TODO: variable expansion (https://crates.io/crates/shellexpand) BEFORE parsing let command_str = task_node.resolved_config.command.as_str(); if let Some(parsed_subcommands) = try_parse_as_and_list(command_str) { let mut items = Vec::::with_capacity(parsed_subcommands.len()); 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()); + + // Add prefix envs to the context + context.add_envs(and_item.envs.iter()); + // Try to parse the args of an and_item to known vite subcommands like `run -r build` let parsed_subcommand = context - .callbacks + .callbacks() .parse_args(&and_item.program, &and_item.args) - .map_err(|error| TaskPlanError::CallbackParseArgsError { - subcommand: (&command_str[add_item_span.clone()]).into(), - error, - })?; + .map_err(|error| TaskPlanErrorKind::CallbackParseArgsError { error }) + .with_task_call_stack(&context)?; let execution_item_kind: ExecutionItemKind = match parsed_subcommand { // Expand task query like `vite run -r build` Some(Subcommand::QueryTasks(query_tasks_subcommand)) => { - // Add envs from the prefix to the new context - - // let execution_graph = - // expand_into_execution_graph(query_tasks_subcommand, new_context).await?; - // ExecutionItemKind::Expanded(execution_graph) - todo!() + let execution_graph = + expand_into_execution_graph(query_tasks_subcommand, context).await?; + ExecutionItemKind::Expanded(execution_graph) } Some(Subcommand::Synthetic { name, extra_args }) => { // Synthetic task, like `vite lint` @@ -124,18 +142,21 @@ pub async fn resolve_task_to_execution_node( } else { } + // context.task_call_stack.pop(); + todo!() } /// Expand the parsed command arguments (like `-r build`) into an execution graph. pub async fn expand_into_execution_graph( query_tasks_subcommand: QueryTasksSubcommand, - context: PlanContext<'_>, + mut context: PlanContext<'_>, ) -> Result, Error> { - let indexed_task_graph = context.callbacks.load_task_graph().await?; + let indexed_task_graph = context.indexed_task_graph(); // Query matching tasks from the task graph - let task_node_index_graph = indexed_task_graph.query_tasks(query_tasks_subcommand.query)?; + let task_node_index_graph = + indexed_task_graph.query_tasks(query_tasks_subcommand.query).unwrap(); let task_graph = indexed_task_graph.task_graph(); for (from_task_index, to_task_index, ()) in task_node_index_graph.all_edges() { diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 63cfd8dc..7622f29d 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -3,6 +3,7 @@ mod envs; mod error; mod expand; mod leaf; +mod path_env; use std::{ collections::{BTreeMap, HashMap}, @@ -124,10 +125,6 @@ pub enum ExecutionItemKind { /// Callbackes needed during planning. /// See each method for details. pub trait PlanCallbacks: Debug { - fn load_task_graph<'me>( - &'me self, - ) -> BoxFuture<'me, Result<&'me IndexedTaskGraph, vite_task_graph::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. 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..5e7c0f6d --- /dev/null +++ b/crates/vite_task_plan/src/path_env.rs @@ -0,0 +1,44 @@ +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(()) +} From 51ee26f5ee6fb5bc3b0921e9bb3402bc2c3c44b8 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 14 Dec 2025 20:44:26 +0800 Subject: [PATCH 09/26] refactor(vite_task_plan): move path prepending tests to path_env.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move path-related tests from envs.rs to path_env.rs to better organize the test suite following the code reorganization. Changes: - Move 5 path prepending tests to path_env.rs - Remove 3 obsolete package_path tests that tested removed functionality - Clean up unused imports in envs.rs - Prefix unused parameters with underscores 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- crates/vite_task_plan/src/envs.rs | 201 +------------------------- crates/vite_task_plan/src/path_env.rs | 103 +++++++++++++ 2 files changed, 108 insertions(+), 196 deletions(-) diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index 97dcb9fa..eb9279a7 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -1,15 +1,14 @@ use std::{ collections::{BTreeMap, HashMap}, - env::{self, join_paths, split_paths}, - ffi::{OsStr, OsString}, - path::{self, PathBuf}, + env, + ffi::OsStr, sync::Arc, }; use sha2::{Digest as _, Sha256}; use supports_color::{Stream, on}; use vite_glob::GlobPatternSet; -use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_path::AbsolutePath; use vite_str::Str; use vite_task_graph::config::EnvConfig; @@ -61,8 +60,8 @@ impl ResolvedEnvs { pub fn resolve( all_envs: &mut Arc, Arc>>, env_config: &EnvConfig, - package_path: Option<&AbsolutePath>, - workspace_root: &AbsolutePath, + _package_path: Option<&AbsolutePath>, + _workspace_root: &AbsolutePath, ) -> Result { // Collect all envs matching fingerpinted or pass-through envs in env_config *all_envs = Arc::new({ @@ -436,96 +435,6 @@ mod tests { ); } - #[test] - #[cfg(windows)] - fn test_windows_path_case_insensitive_mixed_case() { - let workspace_root = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); - - let env_config = create_env_config(&[], &["Path", "OTHER_VAR"]); - - let mut all_envs = - create_test_envs(vec![("Path", "C:\\existing\\path"), ("OTHER_VAR", "value")]); - - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); - - // Verify that the original "Path" casing is preserved, not "PATH" - assert!(all_envs.contains_key(OsStr::new("Path"))); - assert!(!all_envs.contains_key(OsStr::new("PATH"))); - - // Verify the PATH value has node_modules/.bin prepended - let path_value = all_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<_> = all_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 workspace_root = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); - - let env_config = create_env_config(&[], &["PATH", "OTHER_VAR"]); - - let mut all_envs = - create_test_envs(vec![("PATH", "C:\\existing\\path"), ("OTHER_VAR", "value")]); - - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); - - // Verify the PATH value has node_modules/.bin prepended - let path_value = all_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 workspace_root = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); - - let env_config = create_env_config(&[], &["OTHER_VAR"]); - - let mut all_envs = create_test_envs(vec![("OTHER_VAR", "value")]); - - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); - - // Verify PATH was created with only node_modules/.bin - let path_value = all_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 workspace_root = AbsolutePath::new("/workspace/packages/app").unwrap(); - - let env_config = create_env_config(&[], &["PATH", "OTHER_VAR"]); - - let mut all_envs = - create_test_envs(vec![("PATH", "/existing/path"), ("OTHER_VAR", "value")]); - - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); - - // Verify "PATH" exists and the complete value has node_modules/.bin prepended - let path_value = all_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!(!all_envs.contains_key(OsStr::new("Path"))); - assert!(!all_envs.contains_key(OsStr::new("path"))); - } - // ============================================ // New tests for changed/new logic // ============================================ @@ -588,84 +497,6 @@ mod tests { assert!(result.pass_through_envs.iter().any(|s| s.as_str() == "CI")); } - #[test] - #[cfg(unix)] - fn test_package_path_equals_workspace_root() { - // When package_path == workspace_root, only one node_modules/.bin should be added - let workspace_root = AbsolutePath::new("/workspace").unwrap(); - let package_path = AbsolutePath::new("/workspace").unwrap(); - - let env_config = create_env_config(&[], &["PATH"]); - - let mut all_envs = create_test_envs(vec![("PATH", "/existing/path")]); - - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, Some(package_path), workspace_root) - .unwrap(); - - let path_value = all_envs.get(OsStr::new("PATH")).unwrap(); - let path_str = path_value.to_str().unwrap(); - - // Should only contain one node_modules/.bin entry (workspace root) - let node_modules_count = path_str.matches("node_modules/.bin").count(); - assert_eq!( - node_modules_count, 1, - "Should have exactly one node_modules/.bin when package_path == workspace_root" - ); - } - - #[test] - #[cfg(unix)] - fn test_package_path_different_from_workspace_root() { - // When paths differ, both node_modules/.bin paths should be added - let workspace_root = AbsolutePath::new("/workspace").unwrap(); - let package_path = AbsolutePath::new("/workspace/packages/app").unwrap(); - - let env_config = create_env_config(&[], &["PATH"]); - - let mut all_envs = create_test_envs(vec![("PATH", "/existing/path")]); - - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, Some(package_path), workspace_root) - .unwrap(); - - let path_value = all_envs.get(OsStr::new("PATH")).unwrap(); - let path_str = path_value.to_str().unwrap(); - - // Should contain two node_modules/.bin entries - let node_modules_count = path_str.matches("node_modules/.bin").count(); - assert_eq!(node_modules_count, 2, "Should have two node_modules/.bin when paths differ"); - - // Package path should come before workspace path - let package_pos = path_str.find("/workspace/packages/app/node_modules/.bin"); - let workspace_pos = path_str.find("/workspace/node_modules/.bin"); - assert!(package_pos.is_some()); - assert!(workspace_pos.is_some()); - assert!(package_pos.unwrap() < workspace_pos.unwrap(), "Package path should come first"); - } - - #[test] - #[cfg(unix)] - fn test_package_path_none() { - // When package_path is None, only workspace_root/node_modules/.bin should be added - let workspace_root = AbsolutePath::new("/workspace").unwrap(); - - let env_config = create_env_config(&[], &["PATH"]); - - let mut all_envs = create_test_envs(vec![("PATH", "/existing/path")]); - - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); - - let path_value = all_envs.get(OsStr::new("PATH")).unwrap(); - let path_str = path_value.to_str().unwrap(); - - // Should contain only workspace root's node_modules/.bin - assert!(path_str.contains("/workspace/node_modules/.bin")); - let node_modules_count = path_str.matches("node_modules/.bin").count(); - assert_eq!(node_modules_count, 1); - } - #[test] fn test_all_envs_mutated_after_resolve() { let workspace_root = if cfg!(windows) { @@ -722,28 +553,6 @@ mod tests { } } - #[test] - #[cfg(unix)] - fn test_prepend_paths_removes_duplicates() { - let workspace_root = AbsolutePath::new("/workspace").unwrap(); - - let env_config = create_env_config(&[], &["PATH"]); - - // PATH already contains the node_modules/.bin path - let mut all_envs = - create_test_envs(vec![("PATH", "/workspace/node_modules/.bin:/other/path")]); - - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); - - let path_value = all_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"); - } - #[test] fn test_sensitive_env_hashing() { let workspace_root = if cfg!(windows) { diff --git a/crates/vite_task_plan/src/path_env.rs b/crates/vite_task_plan/src/path_env.rs index 5e7c0f6d..3a246b1b 100644 --- a/crates/vite_task_plan/src/path_env.rs +++ b/crates/vite_task_plan/src/path_env.rs @@ -42,3 +42,106 @@ pub fn prepend_path_env( *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"); + } +} From f5597149bd2b726ac32ffdc74c93d3874b703bb2 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 14 Dec 2025 20:49:45 +0800 Subject: [PATCH 10/26] wip --- crates/vite_task_plan/src/envs.rs | 7 ++----- crates/vite_task_plan/src/expand.rs | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index eb9279a7..815653dd 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -54,14 +54,11 @@ impl ResolvedEnvs { /// 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). /// - /// node_modules/.bin under package and workspace root will be added to PATH env. - /// - /// `package_path` can be `None` if the task is not associated with any package (e.g. synthetic tasks). + /// `should_also_fingerprint` is a callback to determine additional envs to fingerprint. + /// It's for prefix envs in the command, which are always fingerprinted even if not declared in `env_config`. pub fn resolve( all_envs: &mut Arc, Arc>>, env_config: &EnvConfig, - _package_path: Option<&AbsolutePath>, - _workspace_root: &AbsolutePath, ) -> Result { // Collect all envs matching fingerpinted or pass-through envs in env_config *all_envs = Arc::new({ diff --git a/crates/vite_task_plan/src/expand.rs b/crates/vite_task_plan/src/expand.rs index 5af28288..9cadbb3f 100644 --- a/crates/vite_task_plan/src/expand.rs +++ b/crates/vite_task_plan/src/expand.rs @@ -119,6 +119,23 @@ pub async fn resolve_task_to_execution_node( todo!() } None => { + // let resolved_cache_config = task_node.resolved_config.cache_config.map( + // |cache_config| ResolvedCacheConfig { + // resolved_envs: ResolvedEnvs::resolve( + // &cache_config.resolved_envs, + // &context.all_envs, + // &context.cwd, + // &context.indexed_task_graph(), + // ) + // .map_err(|error| TaskPlanErrorKind::ResolveCacheConfigEnvError { + // task_display: context + // .indexed_task_graph() + // .display_task(task_node_index), + // source: error, + // }) + // .with_task_call_stack(&context)?, + // }, + // ); // Normal 3rd party tool command (like `tsc --noEmit`) // ExecutionItemKind::Leaf(LeafExecutionItem { // resolved_cache_config: task_node.resolved_config.cache_config.map( @@ -155,8 +172,10 @@ pub async fn expand_into_execution_graph( let indexed_task_graph = context.indexed_task_graph(); // Query matching tasks from the task graph - let task_node_index_graph = - indexed_task_graph.query_tasks(query_tasks_subcommand.query).unwrap(); + let task_node_index_graph = indexed_task_graph + .query_tasks(query_tasks_subcommand.query) + .map_err(TaskPlanErrorKind::TaskQueryError) + .with_task_call_stack(&context)?; let task_graph = indexed_task_graph.task_graph(); for (from_task_index, to_task_index, ()) in task_node_index_graph.all_edges() { From b0ba5b7a8c8c0556c76d37fbb1e14be621322a7b Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 14 Dec 2025 20:53:28 +0800 Subject: [PATCH 11/26] fix(vite_task_plan): remove extra parameters from ResolvedEnvs::resolve test calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update test calls to match the actual function signature which only takes 2 parameters (all_envs and env_config). Remove unused workspace_root variables and the unused vite_path::AbsolutePath import. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crates/vite_task_plan/src/envs.rs | 86 ++++++------------------------- 1 file changed, 16 insertions(+), 70 deletions(-) diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index 815653dd..937e658b 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -8,7 +8,6 @@ use std::{ use sha2::{Digest as _, Sha256}; use supports_color::{Stream, on}; use vite_glob::GlobPatternSet; -use vite_path::AbsolutePath; use vite_str::Str; use vite_task_graph::config::EnvConfig; @@ -218,18 +217,11 @@ mod tests { #[test] fn test_force_color_auto_detection() { - let workspace_root = if cfg!(windows) { - AbsolutePath::new("C:\\workspace").unwrap() - } else { - AbsolutePath::new("/workspace").unwrap() - }; - // 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, None, workspace_root).unwrap(); + 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 @@ -245,8 +237,7 @@ mod tests { 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, None, workspace_root).unwrap(); + 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"))); @@ -260,8 +251,7 @@ mod tests { 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, None, workspace_root).unwrap(); + 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(); @@ -273,8 +263,6 @@ mod tests { #[test] #[cfg(unix)] fn test_task_envs_stable_ordering() { - let workspace_root = AbsolutePath::new("/workspace").unwrap(); - // Create env config with multiple envs let env_config = create_env_config( &["ZEBRA_VAR", "ALPHA_VAR", "MIDDLE_VAR", "BETA_VAR", "NOT_EXISTS_VAR", "APP?_*"], @@ -303,12 +291,9 @@ mod tests { 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, None, workspace_root).unwrap(); - let result2 = - ResolvedEnvs::resolve(&mut all_envs2, &env_config, None, workspace_root).unwrap(); - let result3 = - ResolvedEnvs::resolve(&mut all_envs3, &env_config, None, workspace_root).unwrap(); + 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(); @@ -349,8 +334,6 @@ mod tests { #[cfg(unix)] fn test_unix_env_case_sensitive() { // Test that Unix environment variable matching is case-sensitive - let workspace_root = AbsolutePath::new("/workspace").unwrap(); - // Create env config with envs in different cases let env_config = create_env_config(&["TEST_VAR", "test_var", "Test_Var"], &[]); @@ -361,8 +344,7 @@ mod tests { ("Test_Var", "mixed"), ]); - let result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + 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 @@ -380,8 +362,6 @@ mod tests { #[test] #[cfg(windows)] fn test_windows_env_case_insensitive() { - let workspace_root = AbsolutePath::new("C:\\workspace").unwrap(); - let env_config = create_env_config( &["ZEBRA_VAR", "ALPHA_VAR", "MIDDLE_VAR", "BETA_VAR", "NOT_EXISTS_VAR", "APP?_*"], &["Path", "VSCODE_VAR"], @@ -402,12 +382,9 @@ mod tests { 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, None, workspace_root).unwrap(); - let result2 = - ResolvedEnvs::resolve(&mut all_envs2, &env_config, None, workspace_root).unwrap(); - let result3 = - ResolvedEnvs::resolve(&mut all_envs3, &env_config, None, workspace_root).unwrap(); + 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(); @@ -439,12 +416,6 @@ mod tests { #[test] fn test_btreemap_stable_fingerprint() { // Verify BTreeMap produces identical ordering regardless of insertion order - let workspace_root = if cfg!(windows) { - AbsolutePath::new("C:\\workspace").unwrap() - } else { - AbsolutePath::new("/workspace").unwrap() - }; - let env_config = create_env_config(&["AAA", "ZZZ", "MMM", "BBB"], &[]); // Create envs in different orders @@ -453,10 +424,8 @@ mod tests { 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, None, workspace_root).unwrap(); - let result2 = - ResolvedEnvs::resolve(&mut all_envs2, &env_config, None, workspace_root).unwrap(); + 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(); @@ -469,12 +438,6 @@ mod tests { #[test] fn test_pass_through_envs_names_stored() { - let workspace_root = if cfg!(windows) { - AbsolutePath::new("C:\\workspace").unwrap() - } else { - AbsolutePath::new("/workspace").unwrap() - }; - let env_config = create_env_config(&["BUILD_MODE"], &["PATH", "HOME", "CI"]); let mut all_envs = create_test_envs(vec![ @@ -484,8 +447,7 @@ mod tests { ("CI", "true"), ]); - let result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + 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); @@ -496,12 +458,6 @@ mod tests { #[test] fn test_all_envs_mutated_after_resolve() { - let workspace_root = if cfg!(windows) { - AbsolutePath::new("C:\\workspace").unwrap() - } else { - AbsolutePath::new("/workspace").unwrap() - }; - // Include some envs that should be filtered out let env_config = create_env_config(&["KEEP_THIS"], &["PASS_THROUGH"]); @@ -512,8 +468,7 @@ mod tests { ("ANOTHER_FILTERED", "also filtered"), ]); - let _result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + 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"))); @@ -527,8 +482,6 @@ mod tests { fn test_error_env_value_not_valid_unicode() { use std::os::unix::ffi::OsStrExt; - let workspace_root = AbsolutePath::new("/workspace").unwrap(); - let env_config = create_env_config(&["INVALID_UTF8"], &[]); // Create invalid UTF-8 sequence @@ -539,7 +492,7 @@ mod tests { .collect(), ); - let result = ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root); + let result = ResolvedEnvs::resolve(&mut all_envs, &env_config); assert!(result.is_err()); match result.unwrap_err() { @@ -552,12 +505,6 @@ mod tests { #[test] fn test_sensitive_env_hashing() { - let workspace_root = if cfg!(windows) { - AbsolutePath::new("C:\\workspace").unwrap() - } else { - AbsolutePath::new("/workspace").unwrap() - }; - // Test various sensitive patterns let env_config = create_env_config( &["API_KEY", "MY_SECRET", "AUTH_TOKEN", "DB_PASSWORD", "NORMAL_VAR"], @@ -572,8 +519,7 @@ mod tests { ("NORMAL_VAR", "normal_value"), ]); - let result = - ResolvedEnvs::resolve(&mut all_envs, &env_config, None, workspace_root).unwrap(); + 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:")); From 6383a7291412a072efc2dad2cf6643dfeab8c85e Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 14 Dec 2025 22:12:04 +0800 Subject: [PATCH 12/26] wip --- crates/vite_task_graph/src/config/mod.rs | 4 +- crates/vite_task_graph/src/lib.rs | 5 + crates/vite_task_plan/src/context.rs | 2 +- crates/vite_task_plan/src/envs.rs | 33 +-- crates/vite_task_plan/src/error.rs | 22 +- crates/vite_task_plan/src/execution_graph.rs | 25 +++ crates/vite_task_plan/src/expand.rs | 203 ++++++++----------- crates/vite_task_plan/src/lib.rs | 84 +++----- crates/vite_task_plan/src/task_request.rs | 38 ++++ 9 files changed, 202 insertions(+), 214 deletions(-) create mode 100644 crates/vite_task_plan/src/execution_graph.rs create mode 100644 crates/vite_task_plan/src/task_request.rs diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 78031218..9e7e54e8 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -23,7 +23,7 @@ pub struct ResolvedUserTaskConfig { pub command: Str, /// The working directory for the task - pub cwd: AbsolutePathBuf, + pub cwd: Arc, /// Cache-related config. None means caching is disabled. pub cache_config: Option, @@ -91,7 +91,7 @@ impl ResolvedUserTaskConfig { }) } }; - Ok(Self { command: command.into(), cwd, cache_config }) + Ok(Self { command: command.into(), cwd: cwd.into(), cache_config }) } } diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index 600aeca7..4dbeded7 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -457,4 +457,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_plan/src/context.rs b/crates/vite_task_plan/src/context.rs index c915ef5f..77ea0cb4 100644 --- a/crates/vite_task_plan/src/context.rs +++ b/crates/vite_task_plan/src/context.rs @@ -105,7 +105,7 @@ impl<'a> PlanContext<'a> { Ok(()) } - pub fn indexed_task_graph(&self) -> &IndexedTaskGraph { + pub fn indexed_task_graph(&self) -> &'a IndexedTaskGraph { self.indexed_task_graph } diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index 937e658b..e75e71f9 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -39,28 +39,18 @@ pub enum ResolveEnvError { #[error("Env value is not valid unicode: {key} = {value:?}")] EnvValueIsNotValidUnicode { key: Str, value: Arc }, - - #[error("Failed to join paths for PATH env")] - JoinPathsError { - #[source] - #[from] - join_paths_error: env::JoinPathsError, - }, } 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). - /// - /// `should_also_fingerprint` is a callback to determine additional envs to fingerprint. - /// It's for prefix envs in the command, which are always fingerprinted even if not declared in `env_config`. pub fn resolve( - all_envs: &mut Arc, Arc>>, + all_envs: &mut HashMap, Arc>, env_config: &EnvConfig, ) -> Result { // Collect all envs matching fingerpinted or pass-through envs in env_config - *all_envs = Arc::new({ + *all_envs = { let mut new_all_envs = resolve_envs_with_patterns( all_envs.iter(), &env_config @@ -93,7 +83,7 @@ impl ResolvedEnvs { ); } new_all_envs - }); + }; // Resolve fingerprinted envs let mut fingerprinted_envs = BTreeMap::>::new(); @@ -197,13 +187,11 @@ mod tests { use super::*; - fn create_test_envs(pairs: Vec<(&str, &str)>) -> Arc, Arc>> { - Arc::new( - pairs - .into_iter() - .map(|(k, v)| (Arc::from(OsStr::new(k)), Arc::from(OsStr::new(v)))) - .collect(), - ) + 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 { @@ -486,11 +474,10 @@ mod tests { // Create invalid UTF-8 sequence let invalid_utf8 = OsStr::from_bytes(&[0xff, 0xfe]); - let mut all_envs: Arc, Arc>> = Arc::new( + let mut all_envs: HashMap, Arc> = [(Arc::from(OsStr::new("INVALID_UTF8")), Arc::from(invalid_utf8))] .into_iter() - .collect(), - ); + .collect(); let result = ResolvedEnvs::resolve(&mut all_envs, &env_config); diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index b1613dbd..53a1157b 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -1,11 +1,10 @@ -use std::{env::JoinPathsError, sync::Arc}; +use std::env::JoinPathsError; -use vite_path::AbsolutePath; -use vite_str::Str; -use vite_task_graph::{TaskNodeIndex, display::TaskDispay}; +use vite_task_graph::display::TaskDispay; -use crate::context::{ - PlanContext, TaskCallStackDisplay, TaskCallStackFrameDisplay, TaskCycleError, +use crate::{ + context::{PlanContext, TaskCallStackDisplay, TaskCycleError}, + envs::ResolveEnvError, }; /// Errors that can occur when planning a specific execution from a task . @@ -28,8 +27,8 @@ pub enum TaskPlanErrorKind { #[error(transparent)] TaskCycleDetected(#[from] TaskCycleError), - #[error("Failed to parse command")] - CallbackParseArgsError { + #[error("Failed to parse command as task request")] + ParseAsTaskRequestError { #[source] error: anyhow::Error, }, @@ -43,6 +42,9 @@ pub enum TaskPlanErrorKind { #[source] join_paths_error: JoinPathsError, }, + + #[error("Failed to resolve environment variables")] + ResolveEnvError(#[source] ResolveEnvError), } #[derive(Debug, thiserror::Error)] @@ -54,14 +56,16 @@ pub struct Error { kind: TaskPlanErrorKind, } -pub trait TaskPlanErrorKindResultExt { +pub(crate) trait TaskPlanErrorKindResultExt { type Ok; + /// Attach the current task call stack from the planning context to the error. fn with_task_call_stack(self, context: &PlanContext<'_>) -> Result; } impl TaskPlanErrorKindResultExt for Result { type Ok = T; + /// Attach the current task call stack from the planning context to the error. fn with_task_call_stack(self, context: &PlanContext<'_>) -> Result { match self { Ok(value) => Ok(value), 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/expand.rs b/crates/vite_task_plan/src/expand.rs index 9cadbb3f..374e9327 100644 --- a/crates/vite_task_plan/src/expand.rs +++ b/crates/vite_task_plan/src/expand.rs @@ -1,73 +1,21 @@ -use core::task; -use std::{collections::HashMap, ffi::OsStr, sync::Arc, task::Context}; +use std::{borrow::Cow, collections::HashMap, sync::Arc}; -use petgraph::graph::DiGraph; use vite_shell::try_parse_as_and_list; -use vite_task_graph::{IndexedTaskGraph, TaskGraph, TaskNodeIndex}; +use vite_task_graph::TaskNodeIndex; use crate::{ - ExecutionGraphNode, ExecutionItem, ExecutionItemKind, LeafExecutionItem, PlanContext, - QueryTasksSubcommand, ResolvedCacheConfig, Subcommand, + ExecutionItem, ExecutionItemKind, PlanContext, ResolvedCacheConfig, SpawnCommandKind, + SpawnExecutionItem, TaskExecution, envs::ResolvedEnvs, error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, + execution_graph::{ExecutionGraph, ExecutionNodeIndex}, + task_request::{QueryTaskRequest, TaskRequest}, }; -/* - - -#[derive(Debug, thiserror::Error)] -pub enum ExecutionExpansionError { - #[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, - ), -} - -impl ExpandedExecutionItem { - pub async fn expand_from( - parsed_args: ExpansionArgs, - context: PlanContext<'_>, - ) -> Result { - match parsed_args { - ExpansionArgs::QueryTaskGraph { query, plan_options: _ } => { - // Load the task graph - let indexed_task_graph = context.callbacks.load_task_graph().await?; - - // Expand the task query into execution graph - let task_execution_graph = indexed_task_graph.query_tasks(query)?; - - // Resolve each task node into execution nodes - let task_graph = indexed_task_graph.task_graph(); - for (from_task_index, to_task_index, ()) in task_execution_graph.all_edges() { - let from_task = &task_graph[from_task_index]; - let to_task = &task_graph[to_task_index]; - } - } - ExpansionArgs::Synthetic { name, extra_args } => { - todo!() - } - } - todo!() - } -} - -*/ - -pub async fn resolve_task_to_execution_node( - indexed_task_graph: &IndexedTaskGraph, +pub fn plan_task_as_execution_node( task_node_index: TaskNodeIndex, mut context: PlanContext<'_>, -) -> Result { - let task_node = &indexed_task_graph.task_graph()[task_node_index]; - +) -> Result { // Check for cycles in the task call stack. context .check_cycle(task_node_index) @@ -75,21 +23,25 @@ pub async fn resolve_task_to_execution_node( .with_task_call_stack(&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( - &indexed_task_graph - .get_package_path(task_node.task_id.package_index) - .join("node_modules") - .join(".bin"), - ) + .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_task_call_stack(&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(); + + // 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) { let mut items = Vec::::with_capacity(parsed_subcommands.len()); for (and_item, add_item_span) in parsed_subcommands { @@ -100,58 +52,47 @@ pub async fn resolve_task_to_execution_node( // Add prefix envs to the context context.add_envs(and_item.envs.iter()); - // Try to parse the args of an and_item to known vite subcommands like `run -r build` - let parsed_subcommand = context + // Try to parse the args of an and_item to a task request like `run -r build` + let task_request = context .callbacks() - .parse_args(&and_item.program, &and_item.args) - .map_err(|error| TaskPlanErrorKind::CallbackParseArgsError { error }) + .parse_as_task_request(&and_item.program, &and_item.args) + .map_err(|error| TaskPlanErrorKind::ParseAsTaskRequestError { error }) .with_task_call_stack(&context)?; - let execution_item_kind: ExecutionItemKind = match parsed_subcommand { + let execution_item_kind: ExecutionItemKind = match task_request { // Expand task query like `vite run -r build` - Some(Subcommand::QueryTasks(query_tasks_subcommand)) => { + Some(TaskRequest::Query(query_task_request)) => { let execution_graph = - expand_into_execution_graph(query_tasks_subcommand, context).await?; + plan_task_request_as_execution_graph(query_task_request, context)?; ExecutionItemKind::Expanded(execution_graph) } - Some(Subcommand::Synthetic { name, extra_args }) => { - // Synthetic task, like `vite lint` + // Synthetic task, like `vite lint` + Some(TaskRequest::Synthetic(synthetic_task_request)) => { todo!() } + // Normal 3rd party tool command (like `tsc --noEmit`) None => { - // let resolved_cache_config = task_node.resolved_config.cache_config.map( - // |cache_config| ResolvedCacheConfig { - // resolved_envs: ResolvedEnvs::resolve( - // &cache_config.resolved_envs, - // &context.all_envs, - // &context.cwd, - // &context.indexed_task_graph(), - // ) - // .map_err(|error| TaskPlanErrorKind::ResolveCacheConfigEnvError { - // task_display: context - // .indexed_task_graph() - // .display_task(task_node_index), - // source: error, - // }) - // .with_task_call_stack(&context)?, - // }, - // ); - // Normal 3rd party tool command (like `tsc --noEmit`) - // ExecutionItemKind::Leaf(LeafExecutionItem { - // resolved_cache_config: task_node.resolved_config.cache_config.map( - // |cache_config| ResolvedCacheConfig { - // resolved_envs: ResolvedEnvs::resolve( - // todo!(), - // todo!(), - // todo!(), - // todo!(), - // )?, - // }, - // ), - // cwd: Arc::clone(&new_context.cwd), - // command_kind: todo!(), - // }) - todo!() + // all envs available in the current context, wrapped in Cow to allow mutation by cache configs. + let mut all_envs = Cow::Borrowed(context.envs()); + + let mut resolved_cache_config = None; + if let Some(cache_config) = &task_node.resolved_config.cache_config { + // Resolve envs according cache configs + let resolved_envs = + ResolvedEnvs::resolve(all_envs.to_mut(), &cache_config.env_config) + .map_err(TaskPlanErrorKind::ResolveEnvError) + .with_task_call_stack(&context)?; + resolved_cache_config = Some(ResolvedCacheConfig { resolved_envs }); + } + ExecutionItemKind::Spawn(SpawnExecutionItem { + all_envs: Arc::new(all_envs.into_owned()), + resolved_cache_config, + cwd: Arc::clone(&task_node.resolved_config.cwd), + command_kind: SpawnCommandKind::Program { + program: and_item.program, + args: and_item.args.into(), + }, + }) } }; items.push(ExecutionItem { command_span: add_item_span, kind: execution_item_kind }); @@ -159,30 +100,46 @@ pub async fn resolve_task_to_execution_node( } else { } - // context.task_call_stack.pop(); - todo!() } -/// Expand the parsed command arguments (like `-r build`) into an execution graph. -pub async fn expand_into_execution_graph( - query_tasks_subcommand: QueryTasksSubcommand, +/// Expand the parsed task request (like `run -r build`/`exec tsc`/`lint`) into an execution graph. +pub fn plan_task_request_as_execution_graph( + query_task_request: QueryTaskRequest, mut context: PlanContext<'_>, -) -> Result, Error> { - let indexed_task_graph = context.indexed_task_graph(); - +) -> Result { // Query matching tasks from the task graph - let task_node_index_graph = indexed_task_graph - .query_tasks(query_tasks_subcommand.query) + let task_node_index_graph = context + .indexed_task_graph() + .query_tasks(query_task_request.query) .map_err(TaskPlanErrorKind::TaskQueryError) .with_task_call_stack(&context)?; - let task_graph = indexed_task_graph.task_graph(); + 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())?; + 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() { - let from_task = &task_graph[from_task_index]; - let to_task = &task_graph[to_task_index]; + execution_graph.add_edge( + execution_node_indices_by_task_index[&from_task_index], + execution_node_indices_by_task_index[&to_task_index], + (), + ); } - // Subcommand::Synthetic { name, extra_args } => {} - todo!() + Ok(execution_graph) } diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 7622f29d..440d8c88 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -1,9 +1,11 @@ mod context; mod envs; mod error; +mod execution_graph; mod expand; mod leaf; mod path_env; +pub mod task_request; use std::{ collections::{BTreeMap, HashMap}, @@ -19,11 +21,14 @@ use envs::ResolvedEnvs; use futures_core::future::BoxFuture; use futures_util::FutureExt; use petgraph::graph::DiGraph; +use task_request::TaskRequest; use vite_path::AbsolutePath; use vite_shell::TaskParsedCommand; use vite_str::Str; use vite_task_graph::{IndexedTaskGraph, TaskNode, TaskNodeIndex, query::TaskQuery}; +use crate::execution_graph::ExecutionGraph; + /* /// Where an execution originates from #[derive(Debug)] @@ -39,18 +44,18 @@ pub enum ExecutionOrigin { } */ -/// Resolved cache configuration for a leaf execution. +/// 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 leaf execution. +/// 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 LeafExecutionItem { +pub struct SpawnExecutionItem { /* /// Where this resolved command originates from pub origin: ExecutionOrigin, @@ -59,38 +64,27 @@ pub struct LeafExecutionItem { pub resolved_cache_config: Option, /// Environment variables to set for the command, including both fingerprinted and pass-through envs. - pub all_envs: Arc>>, + pub all_envs: Arc, Arc>>, /// Current working directory pub cwd: Arc, /// parsed program with args or shell script - pub command_kind: LeafCommandKind, -} - -pub enum LeafTaskResolutionError {} - -impl LeafExecutionItem { - pub fn resolve_from_task( - task_node: &TaskNode, - context: PlanContext, - ) -> Result { - todo!() - } + pub command_kind: SpawnCommandKind, } -/// The kind of a leaf execution +/// The kind of a spawn command #[derive(Debug)] -pub enum LeafCommandKind { +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 + /// A script to be executed by os shell (sh or cmd) ShellScript(Str), } -/// A node in the execution graph, coresponding to a task. +/// 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 ExecutionGraphNode { +pub struct TaskExecution { /// The task index in the task graph pub task_index: TaskNodeIndex, @@ -100,13 +94,13 @@ pub struct ExecutionGraphNode { pub items: Vec, } -/// An execution item, either expanded from a known vite subcommand, or a leaf execution. +/// 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 leaf) is in `LeafExecutionItem.command_kind`. + /// The actual execution info (if this is spawn) is in `SpawnExecutionItem.command_kind`. pub command_span: Range, /// The kind of this execution item @@ -117,9 +111,10 @@ pub struct ExecutionItem { #[derive(Debug)] pub enum ExecutionItemKind { /// Expanded from a known vite subcommand, like `vite run ...` or `vite lint`. - Expanded(DiGraph), - /// A normal leaf execution, like `tsc --noEmit`. - Leaf(LeafExecutionItem), + Expanded(ExecutionGraph), + /// A normal execution that spawns a child process, like `tsc --noEmit`. + Spawn(SpawnExecutionItem), + // In-process function calling execution may be added here in the future. } /// Callbackes needed during planning. @@ -132,35 +127,12 @@ pub trait PlanCallbacks: Debug { /// - 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 `LeafExecution` with the synthetic task. - fn parse_args(&self, program: &str, args: &[Str]) -> anyhow::Result>; -} - -/// The command arguments indicating to run tasks queried from the task graph. -/// For example: `vite run -r build -- arg1 arg2` -#[derive(Debug)] -pub struct QueryTasksSubcommand { - /// 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 parsed command arguments. -#[derive(Debug)] -pub enum Subcommand { - /// The args indicate to run tasks queried from the task graph, like `vite run -r build -- arg1 arg2`. - QueryTasks(QueryTasksSubcommand), - /// The args indicate to run a synthetic task, like `vite lint`. - Synthetic { name: Str, extra_args: Arc<[Str]> }, -} - -#[derive(Debug)] -pub struct PlanOptions { - pub extra_args: Arc<[Str]>, + /// - If it returns `Ok(Some(ParsedArgs::Synthetic)`, the command will become a `SpawnExecution` with the synthetic task. + fn parse_as_task_request( + &self, + program: &str, + args: &[Str], + ) -> anyhow::Result>; } #[derive(Debug)] @@ -177,5 +149,5 @@ impl ExecutionPlan { &self.root_node } - pub async fn plan(&self, args: Subcommand, context: PlanContext<'_>) {} + pub async fn plan(context: PlanContext<'_>) {} } diff --git a/crates/vite_task_plan/src/task_request.rs b/crates/vite_task_plan/src/task_request.rs new file mode 100644 index 00000000..1357290f --- /dev/null +++ b/crates/vite_task_plan/src/task_request.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use vite_str::Str; +use vite_task_graph::query::TaskQuery; + +#[derive(Debug)] +pub struct PlanOptions { + pub extra_args: Arc<[Str]>, +} + +#[derive(Debug)] +pub struct QueryTaskRequest { + /// 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 SyntheticTaskRequest { + /// The name of the synthetic task to run. + pub name: Str, + + /// Extra arguments to pass to the synthetic task. + pub extra_args: Arc<[Str]>, +} + +#[derive(Debug)] +pub enum TaskRequest { + /// The request to run tasks queried from the task graph, like `vite run ...` or `vite run-many ...`. + Query(QueryTaskRequest), + Synthetic(SyntheticTaskRequest), +} From 1751ee40791b95338092e51ef69d612a4657b4b9 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 14 Dec 2025 22:41:31 +0800 Subject: [PATCH 13/26] use task_display in errors --- crates/vite_task_graph/src/display.rs | 9 ++----- crates/vite_task_graph/src/lib.rs | 27 ++++++++++--------- .../packages/test-package/vite.config.json | 3 +-- ... name@transitive-dependency-workspace.snap | 26 +++++++++++------- ... task@transitive-dependency-workspace.snap | 14 +++++++--- 5 files changed, 45 insertions(+), 34 deletions(-) diff --git a/crates/vite_task_graph/src/display.rs b/crates/vite_task_graph/src/display.rs index 612b089e..1cf60ea2 100644 --- a/crates/vite_task_graph/src/display.rs +++ b/crates/vite_task_graph/src/display.rs @@ -17,13 +17,8 @@ pub struct TaskDispay { impl Display for TaskDispay { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}#{} ({})", - self.package_name, - self.task_name, - self.package_path.as_path().display() - ) + // TODO: give an option to display package path as well + write!(f, "{}#{}", self.package_name, self.task_name,) } } diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index 4dbeded7..c16266d2 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -24,6 +24,8 @@ use vite_path::AbsolutePath; use vite_str::Str; use vite_workspace::{PackageNodeIndex, WorkspaceRoot}; +use crate::display::TaskDispay; + #[derive(Debug, Clone, Copy, Serialize)] enum TaskDependencyTypeInner { /// The dependency is explicitly declared by user in `dependsOn`. @@ -85,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: TaskDispay, #[source] error: crate::config::ResolveTaskError, - package_name: Str, - task_name: Str, }, - #[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: TaskDispay, #[source] error: SpecifierLookupError, - specifier: Str, - // Where the dependency specifier is defined - origin_task_id: TaskId, }, } @@ -221,8 +221,11 @@ impl IndexedTaskGraph { ) .map_err(|err| TaskGraphLoadError::ResolveConfigError { error: err, - package_name: package.package_json.name.clone(), - task_name: task_name.clone(), + task_display: TaskDispay { + 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 }; @@ -293,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, diff --git a/crates/vite_task_graph/tests/fixtures/conflict-test/packages/test-package/vite.config.json b/crates/vite_task_graph/tests/fixtures/conflict-test/packages/test-package/vite.config.json index e9ed9643..aca6743c 100644 --- a/crates/vite_task_graph/tests/fixtures/conflict-test/packages/test-package/vite.config.json +++ b/crates/vite_task_graph/tests/fixtures/conflict-test/packages/test-package/vite.config.json @@ -1,8 +1,7 @@ { "tasks": { "test": { - "command": "A=B C=D vite run build", - "envs": ["C"], + "command": "echo Testing", "cache": true, "dependsOn": ["@test/scope-a#b#c"] } 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)), + }, } From e550c08c6c8e4b0fb4641da0fded4ba1cd1e6210 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 14 Dec 2025 23:24:59 +0800 Subject: [PATCH 14/26] wip --- Cargo.lock | 1 + crates/vite_task_plan/Cargo.toml | 1 + crates/vite_task_plan/src/builtin.rs | 24 +++++++++++++++ crates/vite_task_plan/src/lib.rs | 30 +++++++++++++++---- .../vite_task_plan/src/{expand.rs => plan.rs} | 25 ++++++++++++---- 5 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 crates/vite_task_plan/src/builtin.rs rename crates/vite_task_plan/src/{expand.rs => plan.rs} (87%) diff --git a/Cargo.lock b/Cargo.lock index a98a084a..3339f1ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3233,6 +3233,7 @@ name = "vite_task_plan" version = "0.1.0" dependencies = [ "anyhow", + "derive_more", "futures-core", "futures-util", "indexmap", diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index e04b0a2f..48c3bc3c 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] anyhow = { workspace = true } +derive_more = { workspace = true, features = ["debug"] } futures-core = { workspace = true } futures-util = { workspace = true } indexmap = { workspace = true } diff --git a/crates/vite_task_plan/src/builtin.rs b/crates/vite_task_plan/src/builtin.rs new file mode 100644 index 00000000..b4d6c933 --- /dev/null +++ b/crates/vite_task_plan/src/builtin.rs @@ -0,0 +1,24 @@ +use futures_util::FutureExt; + +use crate::{InProcessExecution, InProcessExecutionOutput}; + +pub fn get_builtin_execution( + name: &str, + mut args: impl Iterator>, +) -> Option { + match name { + "echo" => { + let mut stdout: Vec = Vec::new(); + // TODO: handle -n flag + for arg in args { + stdout.extend_from_slice(arg.as_ref().as_bytes()); + stdout.push(b' '); + } + stdout.pop(); // remove last space + Some(InProcessExecution { + func: Box::new(|| async move { InProcessExecutionOutput { stdout } }.boxed()), + }) + } + _ => None, + } +} diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 440d8c88..2301c5d3 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -1,10 +1,11 @@ +mod builtin; mod context; mod envs; mod error; mod execution_graph; -mod expand; mod leaf; mod path_env; +mod plan; pub mod task_request; use std::{ @@ -55,7 +56,7 @@ pub struct ResolvedCacheConfig { /// 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 SpawnExecutionItem { +pub struct SpawnExecution { /* /// Where this resolved command originates from pub origin: ExecutionOrigin, @@ -86,7 +87,7 @@ pub enum SpawnCommandKind { #[derive(Debug)] pub struct TaskExecution { /// The task index in the task graph - pub task_index: TaskNodeIndex, + pub task_node_index: TaskNodeIndex, /// A task's command is splitted by `&&` and expanded into multiple execution items. /// @@ -107,14 +108,33 @@ pub struct ExecutionItem { pub kind: ExecutionItemKind, } +pub struct InProcessExecutionOutput { + pub stdout: Vec, + // stderr, exit code, etc can be added later +} + +#[derive(derive_more::Debug)] +pub struct InProcessExecution { + #[debug(skip)] + func: Box BoxFuture<'static, InProcessExecutionOutput> + Send + Sync>, +} + +/// 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 an in-process function call + InProcess(InProcessExecution), +} + /// An execution item, from a splitted 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`. - Spawn(SpawnExecutionItem), - // In-process function calling execution may be added here in the future. + Leaf(LeafExecutionKind), } /// Callbackes needed during planning. diff --git a/crates/vite_task_plan/src/expand.rs b/crates/vite_task_plan/src/plan.rs similarity index 87% rename from crates/vite_task_plan/src/expand.rs rename to crates/vite_task_plan/src/plan.rs index 374e9327..d6b4d1eb 100644 --- a/crates/vite_task_plan/src/expand.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -4,8 +4,9 @@ use vite_shell::try_parse_as_and_list; use vite_task_graph::TaskNodeIndex; use crate::{ - ExecutionItem, ExecutionItemKind, PlanContext, ResolvedCacheConfig, SpawnCommandKind, - SpawnExecutionItem, TaskExecution, + ExecutionItem, ExecutionItemKind, LeafExecutionKind, PlanContext, ResolvedCacheConfig, + SpawnCommandKind, SpawnExecution, TaskExecution, + builtin::get_builtin_execution, envs::ResolvedEnvs, error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, execution_graph::{ExecutionGraph, ExecutionNodeIndex}, @@ -41,9 +42,10 @@ pub fn plan_task_as_execution_node( // 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) { - let mut items = Vec::::with_capacity(parsed_subcommands.len()); for (and_item, add_item_span) in parsed_subcommands { // Duplicate the context before modifying it for each and_item let mut context = context.duplicate(); @@ -52,6 +54,17 @@ pub fn plan_task_as_execution_node( // Add prefix envs to the context context.add_envs(and_item.envs.iter()); + // Check for builtin commands like `echo ...` + if let Some(builtin_execution) = + 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() @@ -84,7 +97,7 @@ pub fn plan_task_as_execution_node( .with_task_call_stack(&context)?; resolved_cache_config = Some(ResolvedCacheConfig { resolved_envs }); } - ExecutionItemKind::Spawn(SpawnExecutionItem { + ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(SpawnExecution { all_envs: Arc::new(all_envs.into_owned()), resolved_cache_config, cwd: Arc::clone(&task_node.resolved_config.cwd), @@ -92,7 +105,7 @@ pub fn plan_task_as_execution_node( program: and_item.program, args: and_item.args.into(), }, - }) + })) } }; items.push(ExecutionItem { command_span: add_item_span, kind: execution_item_kind }); @@ -100,7 +113,7 @@ pub fn plan_task_as_execution_node( } else { } - todo!() + Ok(TaskExecution { task_node_index, items }) } /// Expand the parsed task request (like `run -r build`/`exec tsc`/`lint`) into an execution graph. From 9256ecd5eabf72e0205f182386c8011b625e8ebb Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 15 Dec 2025 00:15:16 +0800 Subject: [PATCH 15/26] wip --- crates/vite_task_graph/src/config/mod.rs | 12 ++- crates/vite_task_plan/src/envs.rs | 4 +- crates/vite_task_plan/src/plan.rs | 107 ++++++++++++++++------ crates/vite_task_plan/src/task_request.rs | 11 ++- 4 files changed, 97 insertions(+), 37 deletions(-) diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 9e7e54e8..4d15b91b 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -55,7 +55,7 @@ pub enum ResolveTaskError { impl ResolvedUserTaskConfig { pub fn resolve_package_json_script( - package_dir: &AbsolutePath, + package_dir: &Arc, package_json_script: &str, ) -> Self { Self::resolve( @@ -69,7 +69,7 @@ impl ResolvedUserTaskConfig { /// 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 { let command = match (&user_config.command, package_json_script) { @@ -78,7 +78,11 @@ impl ResolvedUserTaskConfig { (Some(cmd), None) => cmd.as_ref(), (None, Some(script)) => script, }; - let cwd = package_dir.join(user_config.cwd_relative_to_package); + let cwd: Arc = if user_config.cwd_relative_to_package.as_str().is_empty() { + Arc::clone(package_dir) + } else { + package_dir.join(user_config.cwd_relative_to_package).into() + }; let cache_config = match user_config.cache_config { UserCacheConfig::Disabled { cache: MustBe!(false) } => None, UserCacheConfig::Enabled { cache: MustBe!(true), envs, mut pass_through_envs } => { @@ -91,7 +95,7 @@ impl ResolvedUserTaskConfig { }) } }; - Ok(Self { command: command.into(), cwd: cwd.into(), cache_config }) + Ok(Self { command: command.into(), cwd, cache_config }) } } diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index e75e71f9..cefecac8 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -20,7 +20,7 @@ pub struct ResolvedEnvs { /// Environment variables that should be fingerprinted for this execution. /// /// Use `BTreeMap` to ensure stable order. - pub fingerprinted_envs: Arc>>, + pub fingerprinted_envs: BTreeMap>, /// Environment variable names that should be passed through without values being fingerprinted. /// @@ -118,7 +118,7 @@ impl ResolvedEnvs { } Ok(Self { - fingerprinted_envs: Arc::new(fingerprinted_envs), + 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), }) diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index d6b4d1eb..304bb03d 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -1,7 +1,14 @@ -use std::{borrow::Cow, collections::HashMap, sync::Arc}; +use std::{ + borrow::Cow, + collections::{BTreeMap, HashMap}, + ffi::OsStr, + sync::Arc, +}; +use vite_path::AbsolutePath; use vite_shell::try_parse_as_and_list; -use vite_task_graph::TaskNodeIndex; +use vite_str::Str; +use vite_task_graph::{TaskNodeIndex, config::ResolvedUserTaskConfig}; use crate::{ ExecutionItem, ExecutionItemKind, LeafExecutionKind, PlanContext, ResolvedCacheConfig, @@ -10,7 +17,7 @@ use crate::{ envs::ResolvedEnvs, error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, execution_graph::{ExecutionGraph, ExecutionNodeIndex}, - task_request::{QueryTaskRequest, TaskRequest}, + task_request::{QueryTaskRequest, SyntheticTaskRequest, TaskRequest}, }; pub fn plan_task_as_execution_node( @@ -51,9 +58,6 @@ pub fn plan_task_as_execution_node( let mut context = context.duplicate(); context.push_stack_frame(task_node_index, add_item_span.clone()); - // Add prefix envs to the context - context.add_envs(and_item.envs.iter()); - // Check for builtin commands like `echo ...` if let Some(builtin_execution) = get_builtin_execution(&and_item.program, and_item.args.iter()) @@ -75,8 +79,10 @@ pub fn plan_task_as_execution_node( let execution_item_kind: ExecutionItemKind = match task_request { // Expand task query like `vite run -r build` Some(TaskRequest::Query(query_task_request)) => { + // Add prefix envs to the context + context.add_envs(and_item.envs.iter()); let execution_graph = - plan_task_request_as_execution_graph(query_task_request, context)?; + plan_query_task_request_as_execution_graph(query_task_request, context)?; ExecutionItemKind::Expanded(execution_graph) } // Synthetic task, like `vite lint` @@ -85,39 +91,88 @@ pub fn plan_task_as_execution_node( } // Normal 3rd party tool command (like `tsc --noEmit`) None => { - // all envs available in the current context, wrapped in Cow to allow mutation by cache configs. - let mut all_envs = Cow::Borrowed(context.envs()); - - let mut resolved_cache_config = None; - if let Some(cache_config) = &task_node.resolved_config.cache_config { - // Resolve envs according cache configs - let resolved_envs = - ResolvedEnvs::resolve(all_envs.to_mut(), &cache_config.env_config) - .map_err(TaskPlanErrorKind::ResolveEnvError) - .with_task_call_stack(&context)?; - resolved_cache_config = Some(ResolvedCacheConfig { resolved_envs }); - } - ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(SpawnExecution { - all_envs: Arc::new(all_envs.into_owned()), - resolved_cache_config, - cwd: Arc::clone(&task_node.resolved_config.cwd), - command_kind: SpawnCommandKind::Program { + let spawn_execution = plan_spawn_execution( + &and_item.envs, + SpawnCommandKind::Program { program: and_item.program, args: and_item.args.into(), }, - })) + &task_node.resolved_config, + 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, + 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_task_request_as_spawn_execution( + synthetic_task_request: SyntheticTaskRequest, +) -> Result { + todo!() + // let resolved_config = + // ResolvedUserTaskConfig::resolve(synthetic_task_request.user_config, &cwd, None) + // .expect("Command conflict/missing for synthetic task should never happen"); + + // SpawnExecution { + + // } + // todo!() +} + +fn plan_spawn_execution( + prefix_envs: &BTreeMap, + command_kind: SpawnCommandKind, + resolved_config: &ResolvedUserTaskConfig, + context: PlanContext<'_>, +) -> Result { + // all envs available in the current context + let mut all_envs = context.envs().clone(); + + let mut resolved_cache_config = None; + if let Some(cache_config) = &resolved_config.cache_config { + // Resolve envs according cache configs + let mut resolved_envs = ResolvedEnvs::resolve(&mut all_envs, &cache_config.env_config) + .map_err(TaskPlanErrorKind::ResolveEnvError) + .with_task_call_stack(&context)?; + + // 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_config.cwd), + command_kind, + }) +} + /// Expand the parsed task request (like `run -r build`/`exec tsc`/`lint`) into an execution graph. -pub fn plan_task_request_as_execution_graph( +pub fn plan_query_task_request_as_execution_graph( query_task_request: QueryTaskRequest, mut context: PlanContext<'_>, ) -> Result { diff --git a/crates/vite_task_plan/src/task_request.rs b/crates/vite_task_plan/src/task_request.rs index 1357290f..da60e16e 100644 --- a/crates/vite_task_plan/src/task_request.rs +++ b/crates/vite_task_plan/src/task_request.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use vite_str::Str; -use vite_task_graph::query::TaskQuery; +use vite_task_graph::{config::UserTaskConfig, query::TaskQuery}; + +use crate::SpawnCommandKind; #[derive(Debug)] pub struct PlanOptions { @@ -23,11 +25,10 @@ pub struct QueryTaskRequest { /// Synthetic tasks are not defined in the task graph, but are generated on-the-fly. #[derive(Debug)] pub struct SyntheticTaskRequest { - /// The name of the synthetic task to run. - pub name: Str, + /// The command to execute in the synthetic task. + pub command_kind: SpawnCommandKind, - /// Extra arguments to pass to the synthetic task. - pub extra_args: Arc<[Str]>, + pub user_config: UserTaskConfig, } #[derive(Debug)] From f3a037ada780c5afe706419356d207059760d2be Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 16 Dec 2025 10:44:07 +0800 Subject: [PATCH 16/26] update --- crates/vite_task_plan/src/builtin.rs | 24 -------- crates/vite_task_plan/src/in_process.rs | 76 +++++++++++++++++++++++++ crates/vite_task_plan/src/lib.rs | 34 ++--------- crates/vite_task_plan/src/plan.rs | 17 +++--- 4 files changed, 90 insertions(+), 61 deletions(-) delete mode 100644 crates/vite_task_plan/src/builtin.rs create mode 100644 crates/vite_task_plan/src/in_process.rs diff --git a/crates/vite_task_plan/src/builtin.rs b/crates/vite_task_plan/src/builtin.rs deleted file mode 100644 index b4d6c933..00000000 --- a/crates/vite_task_plan/src/builtin.rs +++ /dev/null @@ -1,24 +0,0 @@ -use futures_util::FutureExt; - -use crate::{InProcessExecution, InProcessExecutionOutput}; - -pub fn get_builtin_execution( - name: &str, - mut args: impl Iterator>, -) -> Option { - match name { - "echo" => { - let mut stdout: Vec = Vec::new(); - // TODO: handle -n flag - for arg in args { - stdout.extend_from_slice(arg.as_ref().as_bytes()); - stdout.push(b' '); - } - stdout.pop(); // remove last space - Some(InProcessExecution { - func: Box::new(|| async move { InProcessExecutionOutput { stdout } }.boxed()), - }) - } - _ => None, - } -} 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 index 2301c5d3..737599fe 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -1,34 +1,23 @@ -mod builtin; mod context; mod envs; mod error; mod execution_graph; +mod in_process; mod leaf; mod path_env; mod plan; pub mod task_request; -use std::{ - collections::{BTreeMap, HashMap}, - ffi::OsStr, - fmt::Debug, - hash::Hash, - ops::Range, - sync::Arc, -}; +use std::{collections::HashMap, ffi::OsStr, fmt::Debug, ops::Range, sync::Arc}; use context::PlanContext; use envs::ResolvedEnvs; -use futures_core::future::BoxFuture; -use futures_util::FutureExt; -use petgraph::graph::DiGraph; +use execution_graph::ExecutionGraph; +use in_process::InProcessExecution; use task_request::TaskRequest; use vite_path::AbsolutePath; -use vite_shell::TaskParsedCommand; use vite_str::Str; -use vite_task_graph::{IndexedTaskGraph, TaskNode, TaskNodeIndex, query::TaskQuery}; - -use crate::execution_graph::ExecutionGraph; +use vite_task_graph::{TaskNodeIndex, query::TaskQuery}; /* /// Where an execution originates from @@ -108,23 +97,12 @@ pub struct ExecutionItem { pub kind: ExecutionItemKind, } -pub struct InProcessExecutionOutput { - pub stdout: Vec, - // stderr, exit code, etc can be added later -} - -#[derive(derive_more::Debug)] -pub struct InProcessExecution { - #[debug(skip)] - func: Box BoxFuture<'static, InProcessExecutionOutput> + Send + Sync>, -} - /// 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 an in-process function call + /// The execution is done in-process by InProcessExecution::execute() InProcess(InProcessExecution), } diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 304bb03d..0bc004f5 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -13,10 +13,10 @@ use vite_task_graph::{TaskNodeIndex, config::ResolvedUserTaskConfig}; use crate::{ ExecutionItem, ExecutionItemKind, LeafExecutionKind, PlanContext, ResolvedCacheConfig, SpawnCommandKind, SpawnExecution, TaskExecution, - builtin::get_builtin_execution, envs::ResolvedEnvs, error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, execution_graph::{ExecutionGraph, ExecutionNodeIndex}, + in_process::InProcessExecution, task_request::{QueryTaskRequest, SyntheticTaskRequest, TaskRequest}, }; @@ -60,7 +60,7 @@ pub fn plan_task_as_execution_node( // Check for builtin commands like `echo ...` if let Some(builtin_execution) = - get_builtin_execution(&and_item.program, and_item.args.iter()) + InProcessExecution::get_builtin_execution(&and_item.program, and_item.args.iter()) { items.push(ExecutionItem { command_span: add_item_span, @@ -124,15 +124,14 @@ pub fn plan_task_as_execution_node( pub fn plan_synthetic_task_request_as_spawn_execution( synthetic_task_request: SyntheticTaskRequest, ) -> Result { - todo!() - // let resolved_config = - // ResolvedUserTaskConfig::resolve(synthetic_task_request.user_config, &cwd, None) - // .expect("Command conflict/missing for synthetic task should never happen"); + let resolved_config = + ResolvedUserTaskConfig::resolve(synthetic_task_request.user_config, &cwd, None) + .expect("Command conflict/missing for synthetic task should never happen"); - // SpawnExecution { + // SpawnExecution { - // } - // todo!() + // } + todo!() } fn plan_spawn_execution( From 723313a50ae58878d09836eeada8982688a5aadb Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 01:19:36 +0800 Subject: [PATCH 17/26] rename --- crates/vite_task_plan/src/error.rs | 4 ++-- crates/vite_task_plan/src/lib.rs | 6 +++--- crates/vite_task_plan/src/plan.rs | 13 +++++++------ .../src/{task_request.rs => plan_request.rs} | 10 +++++----- 4 files changed, 17 insertions(+), 16 deletions(-) rename crates/vite_task_plan/src/{task_request.rs => plan_request.rs} (86%) diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 53a1157b..04a02c40 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -27,8 +27,8 @@ pub enum TaskPlanErrorKind { #[error(transparent)] TaskCycleDetected(#[from] TaskCycleError), - #[error("Failed to parse command as task request")] - ParseAsTaskRequestError { + #[error("Invalid vite task command")] + ParsePlanRequestError { #[source] error: anyhow::Error, }, diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 737599fe..651d1630 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -6,7 +6,7 @@ mod in_process; mod leaf; mod path_env; mod plan; -pub mod task_request; +pub mod plan_request; use std::{collections::HashMap, ffi::OsStr, fmt::Debug, ops::Range, sync::Arc}; @@ -14,7 +14,7 @@ use context::PlanContext; use envs::ResolvedEnvs; use execution_graph::ExecutionGraph; use in_process::InProcessExecution; -use task_request::TaskRequest; +use plan_request::PlanRequest; use vite_path::AbsolutePath; use vite_str::Str; use vite_task_graph::{TaskNodeIndex, query::TaskQuery}; @@ -130,7 +130,7 @@ pub trait PlanCallbacks: Debug { &self, program: &str, args: &[Str], - ) -> anyhow::Result>; + ) -> anyhow::Result>; } #[derive(Debug)] diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 0bc004f5..7e029930 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -17,7 +17,7 @@ use crate::{ error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, execution_graph::{ExecutionGraph, ExecutionNodeIndex}, in_process::InProcessExecution, - task_request::{QueryTaskRequest, SyntheticTaskRequest, TaskRequest}, + plan_request::{PlanRequest, QueryPlanRequest, SyntheticPlanRequest}, }; pub fn plan_task_as_execution_node( @@ -73,12 +73,12 @@ pub fn plan_task_as_execution_node( let task_request = context .callbacks() .parse_as_task_request(&and_item.program, &and_item.args) - .map_err(|error| TaskPlanErrorKind::ParseAsTaskRequestError { error }) + .map_err(|error| TaskPlanErrorKind::ParsePlanRequestError { error }) .with_task_call_stack(&context)?; let execution_item_kind: ExecutionItemKind = match task_request { // Expand task query like `vite run -r build` - Some(TaskRequest::Query(query_task_request)) => { + Some(PlanRequest::Query(query_task_request)) => { // Add prefix envs to the context context.add_envs(and_item.envs.iter()); let execution_graph = @@ -86,7 +86,7 @@ pub fn plan_task_as_execution_node( ExecutionItemKind::Expanded(execution_graph) } // Synthetic task, like `vite lint` - Some(TaskRequest::Synthetic(synthetic_task_request)) => { + Some(PlanRequest::Synthetic(synthetic_task_request)) => { todo!() } // Normal 3rd party tool command (like `tsc --noEmit`) @@ -122,7 +122,8 @@ pub fn plan_task_as_execution_node( } pub fn plan_synthetic_task_request_as_spawn_execution( - synthetic_task_request: SyntheticTaskRequest, + synthetic_task_request: SyntheticPlanRequest, + cwd: &Arc, ) -> Result { let resolved_config = ResolvedUserTaskConfig::resolve(synthetic_task_request.user_config, &cwd, None) @@ -172,7 +173,7 @@ fn plan_spawn_execution( /// Expand the parsed task request (like `run -r build`/`exec tsc`/`lint`) into an execution graph. pub fn plan_query_task_request_as_execution_graph( - query_task_request: QueryTaskRequest, + query_task_request: QueryPlanRequest, mut context: PlanContext<'_>, ) -> Result { // Query matching tasks from the task graph diff --git a/crates/vite_task_plan/src/task_request.rs b/crates/vite_task_plan/src/plan_request.rs similarity index 86% rename from crates/vite_task_plan/src/task_request.rs rename to crates/vite_task_plan/src/plan_request.rs index da60e16e..d52da2fe 100644 --- a/crates/vite_task_plan/src/task_request.rs +++ b/crates/vite_task_plan/src/plan_request.rs @@ -11,7 +11,7 @@ pub struct PlanOptions { } #[derive(Debug)] -pub struct QueryTaskRequest { +pub struct QueryPlanRequest { /// The query to run against the task graph. For example: `-r build` pub query: TaskQuery, @@ -24,7 +24,7 @@ pub struct QueryTaskRequest { /// 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 SyntheticTaskRequest { +pub struct SyntheticPlanRequest { /// The command to execute in the synthetic task. pub command_kind: SpawnCommandKind, @@ -32,8 +32,8 @@ pub struct SyntheticTaskRequest { } #[derive(Debug)] -pub enum TaskRequest { +pub enum PlanRequest { /// The request to run tasks queried from the task graph, like `vite run ...` or `vite run-many ...`. - Query(QueryTaskRequest), - Synthetic(SyntheticTaskRequest), + Query(QueryPlanRequest), + Synthetic(SyntheticPlanRequest), } From ed09dac9e62b2a77e9a2ae2436614212818671de Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 02:43:56 +0800 Subject: [PATCH 18/26] extract task options from task config --- crates/vite_task_graph/src/config/mod.rs | 83 ++++++++++++++--------- crates/vite_task_graph/src/config/user.rs | 46 +++++++------ crates/vite_task_graph/src/lib.rs | 12 ++-- crates/vite_task_graph/tests/snapshots.rs | 8 ++- crates/vite_task_plan/src/lib.rs | 2 +- crates/vite_task_plan/src/plan.rs | 19 +++--- crates/vite_task_plan/src/plan_request.rs | 1 + 7 files changed, 102 insertions(+), 69 deletions(-) diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 4d15b91b..89ee9aea 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -7,6 +7,8 @@ pub use user::{UserCacheConfig, UserConfigFile, UserTaskConfig}; use vite_path::{AbsolutePath, AbsolutePathBuf}; 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,19 +18,49 @@ 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, + 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 config, package dir + pub fn resolve(user_config: UserTaskOptions, package_dir: &Arc) -> Self { + let cwd: Arc = if user_config.cwd_relative_to_package.as_str().is_empty() { + Arc::clone(package_dir) + } else { + package_dir.join(user_config.cwd_relative_to_package).into() + }; + let cache_config = match user_config.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, @@ -43,7 +75,7 @@ pub struct EnvConfig { } #[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, @@ -53,17 +85,16 @@ pub enum ResolveTaskError { NoCommand, } -impl ResolvedUserTaskConfig { +impl ResolvedTaskConfig { + /// Resolve from package.json script only pub fn resolve_package_json_script( 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). @@ -71,31 +102,17 @@ impl ResolvedUserTaskConfig { user_config: UserTaskConfig, 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: Arc = if user_config.cwd_relative_to_package.as_str().is_empty() { - Arc::clone(package_dir) - } else { - package_dir.join(user_config.cwd_relative_to_package).into() - }; - let cache_config = match user_config.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(), - }, - }) - } - }; - Ok(Self { command: command.into(), cwd, cache_config }) + Ok(Self { + command: command.into(), + resolved_options: ResolvedTaskOptions::resolve(user_config.options, package_dir), + }) } } diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index 45d9a4b8..369c2f67 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -33,13 +33,9 @@ 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,13 +50,15 @@ 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([]), @@ -70,6 +68,18 @@ impl UserTaskConfig { } } +/// 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 +100,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 +112,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 +121,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/lib.rs b/crates/vite_task_graph/src/lib.rs index c16266d2..285754a5 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -11,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}, @@ -79,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)] @@ -98,7 +98,7 @@ pub enum TaskGraphLoadError { ResolveConfigError { task_display: TaskDispay, #[source] - error: crate::config::ResolveTaskError, + error: crate::config::ResolveTaskConfigError, }, #[error("Failed to lookup dependency '{specifier}' for task {task_display}")] @@ -211,10 +211,10 @@ 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, @@ -238,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, ); diff --git a/crates/vite_task_graph/tests/snapshots.rs b/crates/vite_task_graph/tests/snapshots.rs index e33526ac..36d2be71 100644 --- a/crates/vite_task_graph/tests/snapshots.rs +++ b/crates/vite_task_graph/tests/snapshots.rs @@ -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, }); } diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 651d1630..543ee467 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -147,5 +147,5 @@ impl ExecutionPlan { &self.root_node } - pub async fn plan(context: PlanContext<'_>) {} + pub async fn plan(plan_request: PlanRequest, cwd: &Arc) {} } diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 7e029930..d6fa6f14 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -8,12 +8,12 @@ use std::{ use vite_path::AbsolutePath; use vite_shell::try_parse_as_and_list; use vite_str::Str; -use vite_task_graph::{TaskNodeIndex, config::ResolvedUserTaskConfig}; +use vite_task_graph::{TaskNodeIndex, config::ResolvedTaskConfig}; use crate::{ ExecutionItem, ExecutionItemKind, LeafExecutionKind, PlanContext, ResolvedCacheConfig, SpawnCommandKind, SpawnExecution, TaskExecution, - envs::ResolvedEnvs, + envs::{self, ResolvedEnvs}, error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, execution_graph::{ExecutionGraph, ExecutionNodeIndex}, in_process::InProcessExecution, @@ -82,7 +82,7 @@ pub fn plan_task_as_execution_node( // Add prefix envs to the context context.add_envs(and_item.envs.iter()); let execution_graph = - plan_query_task_request_as_execution_graph(query_task_request, context)?; + plan_query_request_as_execution_graph(query_task_request, context)?; ExecutionItemKind::Expanded(execution_graph) } // Synthetic task, like `vite lint` @@ -121,12 +121,13 @@ pub fn plan_task_as_execution_node( Ok(TaskExecution { task_node_index, items }) } -pub fn plan_synthetic_task_request_as_spawn_execution( +pub fn plan_synthetic_request_as_spawn_execution( synthetic_task_request: SyntheticPlanRequest, cwd: &Arc, + envs: &BTreeMap, Arc>, ) -> Result { let resolved_config = - ResolvedUserTaskConfig::resolve(synthetic_task_request.user_config, &cwd, None) + ResolvedTaskConfig::resolve(synthetic_task_request.user_config, &cwd, None) .expect("Command conflict/missing for synthetic task should never happen"); // SpawnExecution { @@ -138,14 +139,14 @@ pub fn plan_synthetic_task_request_as_spawn_execution( fn plan_spawn_execution( prefix_envs: &BTreeMap, command_kind: SpawnCommandKind, - resolved_config: &ResolvedUserTaskConfig, + resolved_config: &ResolvedTaskConfig, context: PlanContext<'_>, ) -> Result { // all envs available in the current context let mut all_envs = context.envs().clone(); let mut resolved_cache_config = None; - if let Some(cache_config) = &resolved_config.cache_config { + if let Some(cache_config) = &resolved_config.resolved_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) @@ -166,13 +167,13 @@ fn plan_spawn_execution( Ok(SpawnExecution { all_envs: Arc::new(all_envs), resolved_cache_config, - cwd: Arc::clone(&resolved_config.cwd), + cwd: Arc::clone(&resolved_config.resolved_options.cwd), command_kind, }) } /// Expand the parsed task request (like `run -r build`/`exec tsc`/`lint`) into an execution graph. -pub fn plan_query_task_request_as_execution_graph( +pub fn plan_query_request_as_execution_graph( query_task_request: QueryPlanRequest, mut context: PlanContext<'_>, ) -> Result { diff --git a/crates/vite_task_plan/src/plan_request.rs b/crates/vite_task_plan/src/plan_request.rs index d52da2fe..2bbe09c7 100644 --- a/crates/vite_task_plan/src/plan_request.rs +++ b/crates/vite_task_plan/src/plan_request.rs @@ -35,5 +35,6 @@ pub struct SyntheticPlanRequest { 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), } From 6e94a64305fbbc1217976eac561c550ebe381246 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 02:46:52 +0800 Subject: [PATCH 19/26] update --- crates/vite_task_graph/src/config/mod.rs | 2 +- crates/vite_task_plan/src/plan.rs | 2 +- crates/vite_task_plan/src/plan_request.rs | 10 +++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 89ee9aea..a70caca6 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -1,4 +1,4 @@ -mod user; +pub mod user; use std::{collections::HashSet, sync::Arc}; diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index d6fa6f14..30caca30 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -127,7 +127,7 @@ pub fn plan_synthetic_request_as_spawn_execution( envs: &BTreeMap, Arc>, ) -> Result { let resolved_config = - ResolvedTaskConfig::resolve(synthetic_task_request.user_config, &cwd, None) + ResolvedTaskConfig::resolve(synthetic_task_request.user_task_options, &cwd, None) .expect("Command conflict/missing for synthetic task should never happen"); // SpawnExecution { diff --git a/crates/vite_task_plan/src/plan_request.rs b/crates/vite_task_plan/src/plan_request.rs index 2bbe09c7..9131fa04 100644 --- a/crates/vite_task_plan/src/plan_request.rs +++ b/crates/vite_task_plan/src/plan_request.rs @@ -1,7 +1,10 @@ use std::sync::Arc; use vite_str::Str; -use vite_task_graph::{config::UserTaskConfig, query::TaskQuery}; +use vite_task_graph::{ + config::{UserTaskConfig, user::UserTaskOptions}, + query::TaskQuery, +}; use crate::SpawnCommandKind; @@ -25,10 +28,11 @@ pub struct QueryPlanRequest { /// Synthetic tasks are not defined in the task graph, but are generated on-the-fly. #[derive(Debug)] pub struct SyntheticPlanRequest { - /// The command to execute in the synthetic task. + /// The command to execute pub command_kind: SpawnCommandKind, - pub user_config: UserTaskConfig, + /// The task options as if it's defined in `vite.config.*` + pub task_options: UserTaskOptions, } #[derive(Debug)] From d8bde694b61d4344006db7542f76908e836f1536 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 03:48:18 +0800 Subject: [PATCH 20/26] fix check warnings --- crates/vite_task_graph/src/config/mod.rs | 14 ++-- crates/vite_task_plan/src/context.rs | 18 ++--- crates/vite_task_plan/src/envs.rs | 1 - crates/vite_task_plan/src/error.rs | 17 ++++- crates/vite_task_plan/src/leaf/mod.rs | 1 - crates/vite_task_plan/src/lib.rs | 53 +++++++++++-- crates/vite_task_plan/src/plan.rs | 92 ++++++++++++----------- crates/vite_task_plan/src/plan_request.rs | 5 +- 8 files changed, 126 insertions(+), 75 deletions(-) delete mode 100644 crates/vite_task_plan/src/leaf/mod.rs diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index a70caca6..67833360 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -4,7 +4,7 @@ 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; @@ -38,14 +38,14 @@ pub struct ResolvedTaskOptions { } impl ResolvedTaskOptions { - /// Resolves from user config, package dir - pub fn resolve(user_config: UserTaskOptions, package_dir: &Arc) -> Self { - let cwd: Arc = if user_config.cwd_relative_to_package.as_str().is_empty() { - Arc::clone(package_dir) + /// 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 { - package_dir.join(user_config.cwd_relative_to_package).into() + dir.join(user_options.cwd_relative_to_package).into() }; - let cache_config = match user_config.cache_config { + 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)); diff --git a/crates/vite_task_plan/src/context.rs b/crates/vite_task_plan/src/context.rs index 77ea0cb4..bf0ee805 100644 --- a/crates/vite_task_plan/src/context.rs +++ b/crates/vite_task_plan/src/context.rs @@ -22,24 +22,26 @@ pub struct TaskCycleError { #[derive(Debug)] pub struct PlanContext<'a> { /// The current working directory. - cwd: Arc, + pub cwd: Arc, /// The environment variables for the current execution context. - envs: HashMap, Arc>, + pub envs: HashMap, Arc>, /// The callbacks for loading task graphs and parsing commands. - callbacks: &'a mut (dyn PlanCallbacks + 'a), + pub callbacks: &'a mut (dyn PlanCallbacks + 'a), /// The current call stack of task index nodes being planned. - task_call_stack: Vec<(TaskNodeIndex, Range)>, + pub task_call_stack: Vec<(TaskNodeIndex, Range)>, - indexed_task_graph: &'a IndexedTaskGraph, + pub indexed_task_graph: &'a IndexedTaskGraph, } /// A human-readable frame in the task call stack. #[derive(Debug, Clone)] pub struct TaskCallStackFrameDisplay { pub task_display: TaskDispay, + + #[expect(dead_code)] // To be used in terminal error display pub command_span: Range, } @@ -51,7 +53,7 @@ impl Display for TaskCallStackFrameDisplay { } /// A human-readable display of the task call stack. -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct TaskCallStackDisplay { frames: Arc<[TaskCallStackFrameDisplay]>, } @@ -68,10 +70,6 @@ impl Display for TaskCallStackDisplay { } } -pub struct TaskCallFrame { - pub task_index: TaskNodeIndex, -} - impl<'a> PlanContext<'a> { pub fn cwd(&self) -> &Arc { &self.cwd diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index cefecac8..4ac930ff 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -1,6 +1,5 @@ use std::{ collections::{BTreeMap, HashMap}, - env, ffi::OsStr, sync::Arc, }; diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 04a02c40..86717768 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -59,14 +59,17 @@ pub struct Error { pub(crate) trait TaskPlanErrorKindResultExt { type Ok; /// Attach the current task call stack from the planning context to the error. - fn with_task_call_stack(self, context: &PlanContext<'_>) -> Result; + 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_task_call_stack(self, context: &PlanContext<'_>) -> Result { + fn with_plan_context(self, context: &PlanContext<'_>) -> Result { match self { Ok(value) => Ok(value), Err(kind) => { @@ -75,4 +78,14 @@ impl TaskPlanErrorKindResultExt for Result { } } } + + 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/leaf/mod.rs b/crates/vite_task_plan/src/leaf/mod.rs deleted file mode 100644 index 82a3da18..00000000 --- a/crates/vite_task_plan/src/leaf/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub fn a() {} diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 543ee467..cb99e19b 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -1,9 +1,8 @@ mod context; mod envs; mod error; -mod execution_graph; +pub mod execution_graph; mod in_process; -mod leaf; mod path_env; mod plan; pub mod plan_request; @@ -12,12 +11,15 @@ 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::{TaskNodeIndex, query::TaskQuery}; +use vite_task_graph::{TaskGraphLoadError, TaskNodeIndex, query::TaskQuery}; /* /// Where an execution originates from @@ -118,6 +120,11 @@ pub enum ExecutionItemKind { /// 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. @@ -126,11 +133,11 @@ pub trait PlanCallbacks: Debug { /// - 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 parse_as_task_request( + fn get_plan_request( &self, program: &str, args: &[Str], - ) -> anyhow::Result>; + ) -> BoxFuture<'_, anyhow::Result>>; } #[derive(Debug)] @@ -147,5 +154,39 @@ impl ExecutionPlan { &self.root_node } - pub async fn plan(plan_request: PlanRequest, cwd: &Arc) {} + 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/plan.rs b/crates/vite_task_plan/src/plan.rs index 30caca30..42a4d2a5 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -1,26 +1,26 @@ use std::{ - borrow::Cow, 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::ResolvedTaskConfig}; +use vite_task_graph::{TaskNodeIndex, config::ResolvedTaskOptions}; use crate::{ ExecutionItem, ExecutionItemKind, LeafExecutionKind, PlanContext, ResolvedCacheConfig, SpawnCommandKind, SpawnExecution, TaskExecution, - envs::{self, ResolvedEnvs}, + envs::ResolvedEnvs, error::{Error, TaskPlanErrorKind, TaskPlanErrorKindResultExt}, execution_graph::{ExecutionGraph, ExecutionNodeIndex}, in_process::InProcessExecution, plan_request::{PlanRequest, QueryPlanRequest, SyntheticPlanRequest}, }; -pub fn plan_task_as_execution_node( +async fn plan_task_as_execution_node( task_node_index: TaskNodeIndex, mut context: PlanContext<'_>, ) -> Result { @@ -28,7 +28,7 @@ pub fn plan_task_as_execution_node( context .check_cycle(task_node_index) .map_err(TaskPlanErrorKind::TaskCycleDetected) - .with_task_call_stack(&context)?; + .with_plan_context(&context)?; // Prepend {package_path}/node_modules/.bin to PATH let package_node_modules_bin_path = context @@ -42,7 +42,7 @@ pub fn plan_task_as_execution_node( task_display: context.indexed_task_graph().display_task(task_node_index), join_paths_error, }) - .with_task_call_stack(&context)?; + .with_plan_context(&context)?; let task_node = &context.indexed_task_graph().task_graph()[task_node_index]; @@ -72,22 +72,29 @@ pub fn plan_task_as_execution_node( // Try to parse the args of an and_item to a task request like `run -r build` let task_request = context .callbacks() - .parse_as_task_request(&and_item.program, &and_item.args) + .get_plan_request(&and_item.program, &and_item.args) + .await .map_err(|error| TaskPlanErrorKind::ParsePlanRequestError { error }) - .with_task_call_stack(&context)?; + .with_plan_context(&context)?; let execution_item_kind: ExecutionItemKind = match task_request { // Expand task query like `vite run -r build` - Some(PlanRequest::Query(query_task_request)) => { + 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_as_execution_graph(query_task_request, context)?; + let execution_graph = plan_query_request(query_plan_request, context).await?; ExecutionItemKind::Expanded(execution_graph) } // Synthetic task, like `vite lint` - Some(PlanRequest::Synthetic(synthetic_task_request)) => { - todo!() + 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 => { @@ -97,9 +104,10 @@ pub fn plan_task_as_execution_node( program: and_item.program, args: and_item.args.into(), }, - &task_node.resolved_config, - context, - )?; + &task_node.resolved_config.resolved_options, + context.envs(), + ) + .with_plan_context(&context)?; ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) } }; @@ -109,9 +117,10 @@ pub fn plan_task_as_execution_node( let spawn_execution = plan_spawn_execution( &BTreeMap::new(), SpawnCommandKind::ShellScript(command_str.into()), - &task_node.resolved_config, - context, - )?; + &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)), @@ -121,36 +130,30 @@ pub fn plan_task_as_execution_node( Ok(TaskExecution { task_node_index, items }) } -pub fn plan_synthetic_request_as_spawn_execution( - synthetic_task_request: SyntheticPlanRequest, +pub fn plan_synthetic_request( + prefix_envs: &BTreeMap, + synthetic_plan_request: SyntheticPlanRequest, cwd: &Arc, - envs: &BTreeMap, Arc>, -) -> Result { - let resolved_config = - ResolvedTaskConfig::resolve(synthetic_task_request.user_task_options, &cwd, None) - .expect("Command conflict/missing for synthetic task should never happen"); - - // SpawnExecution { - - // } - todo!() + 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_config: &ResolvedTaskConfig, - context: PlanContext<'_>, -) -> Result { + resolved_task_options: &ResolvedTaskOptions, + envs: &HashMap, Arc>, +) -> Result { // all envs available in the current context - let mut all_envs = context.envs().clone(); + let mut all_envs = envs.clone(); let mut resolved_cache_config = None; - if let Some(cache_config) = &resolved_config.resolved_options.cache_config { + 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) - .with_task_call_stack(&context)?; + .map_err(TaskPlanErrorKind::ResolveEnvError)?; // Add prefix envs to fingerprinted envs resolved_envs @@ -167,22 +170,22 @@ fn plan_spawn_execution( Ok(SpawnExecution { all_envs: Arc::new(all_envs), resolved_cache_config, - cwd: Arc::clone(&resolved_config.resolved_options.cwd), + 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 fn plan_query_request_as_execution_graph( - query_task_request: QueryPlanRequest, +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_task_request.query) + .query_tasks(query_plan_request.query) .map_err(TaskPlanErrorKind::TaskQueryError) - .with_task_call_stack(&context)?; + .with_plan_context(&context)?; let mut execution_node_indices_by_task_index = HashMap::::with_capacity( @@ -196,7 +199,8 @@ pub fn plan_query_request_as_execution_graph( // 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())?; + 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)); } diff --git a/crates/vite_task_plan/src/plan_request.rs b/crates/vite_task_plan/src/plan_request.rs index 9131fa04..589dd536 100644 --- a/crates/vite_task_plan/src/plan_request.rs +++ b/crates/vite_task_plan/src/plan_request.rs @@ -1,10 +1,7 @@ use std::sync::Arc; use vite_str::Str; -use vite_task_graph::{ - config::{UserTaskConfig, user::UserTaskOptions}, - query::TaskQuery, -}; +use vite_task_graph::{config::user::UserTaskOptions, query::TaskQuery}; use crate::SpawnCommandKind; From 9f9c412e49e131828018f587f486ebe7615991ba Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 03:53:23 +0800 Subject: [PATCH 21/26] camelCase fields --- crates/vite_task_graph/src/config/user.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index 369c2f67..c510316e 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -35,6 +35,7 @@ pub enum UserCacheConfig { /// Options for user-defined tasks in `vite.config.*`, excluding the command. #[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] pub struct UserTaskOptions { /// The working directory for the task, relative to the package root (not workspace root). #[serde(default)] // default to empty if omitted From 51cb41cf72b18113df5eab4a722a088bad225aff Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 03:58:37 +0800 Subject: [PATCH 22/26] cargo shear fix --- Cargo.lock | 2 -- Cargo.toml | 1 - crates/vite_task_plan/Cargo.toml | 2 -- 3 files changed, 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3339f1ea..c4fb365b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3233,10 +3233,8 @@ name = "vite_task_plan" version = "0.1.0" dependencies = [ "anyhow", - "derive_more", "futures-core", "futures-util", - "indexmap", "petgraph", "sha2", "supports-color", diff --git a/Cargo.toml b/Cargo.toml index cc247e87..f0b19e44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,6 @@ fspy_test_utils = { path = "crates/fspy_test_utils" } futures = "0.3.31" futures-core = "0.3.31" futures-util = "0.3.31" -indexmap = "2.12.1" insta = "1.44.3" itertools = "0.14.0" libc = "0.2.172" diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index 48c3bc3c..ccbc124b 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -12,10 +12,8 @@ workspace = true [dependencies] anyhow = { workspace = true } -derive_more = { workspace = true, features = ["debug"] } futures-core = { workspace = true } futures-util = { workspace = true } -indexmap = { workspace = true } petgraph = { workspace = true } sha2 = { workspace = true } supports-color = { workspace = true } From 50a9319f9fce283a72289b41cd13d258bd5ddd25 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 04:05:23 +0800 Subject: [PATCH 23/26] fix typos --- crates/vite_task_graph/src/display.rs | 8 ++++---- crates/vite_task_graph/src/lib.rs | 8 ++++---- crates/vite_task_plan/src/context.rs | 4 ++-- crates/vite_task_plan/src/error.rs | 4 ++-- crates/vite_task_plan/src/lib.rs | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/vite_task_graph/src/display.rs b/crates/vite_task_graph/src/display.rs index 1cf60ea2..78afab73 100644 --- a/crates/vite_task_graph/src/display.rs +++ b/crates/vite_task_graph/src/display.rs @@ -9,13 +9,13 @@ use crate::{IndexedTaskGraph, TaskNodeIndex}; /// struct for printing a task in a human-readable way. #[derive(Debug, Clone)] -pub struct TaskDispay { +pub struct TaskDisplay { pub package_name: Str, pub task_name: Str, pub package_path: Arc, } -impl Display for TaskDispay { +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,) @@ -24,10 +24,10 @@ impl Display for TaskDispay { impl IndexedTaskGraph { /// Get human-readable display for a task node. - pub fn display_task(&self, task_index: TaskNodeIndex) -> TaskDispay { + 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]; - TaskDispay { + 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 285754a5..a258dd9b 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -24,7 +24,7 @@ use vite_path::AbsolutePath; use vite_str::Str; use vite_workspace::{PackageNodeIndex, WorkspaceRoot}; -use crate::display::TaskDispay; +use crate::display::TaskDisplay; #[derive(Debug, Clone, Copy, Serialize)] enum TaskDependencyTypeInner { @@ -96,7 +96,7 @@ pub enum TaskGraphLoadError { #[error("Failed to resolve task config for task {task_display}")] ResolveConfigError { - task_display: TaskDispay, + task_display: TaskDisplay, #[source] error: crate::config::ResolveTaskConfigError, }, @@ -104,7 +104,7 @@ pub enum TaskGraphLoadError { #[error("Failed to lookup dependency '{specifier}' for task {task_display}")] DependencySpecifierLookupError { specifier: Str, - task_display: TaskDispay, + task_display: TaskDisplay, #[source] error: SpecifierLookupError, }, @@ -221,7 +221,7 @@ impl IndexedTaskGraph { ) .map_err(|err| TaskGraphLoadError::ResolveConfigError { error: err, - task_display: TaskDispay { + task_display: TaskDisplay { package_name: package.package_json.name.clone(), task_name: task_name.clone(), package_path: Arc::clone(&package_dir), diff --git a/crates/vite_task_plan/src/context.rs b/crates/vite_task_plan/src/context.rs index bf0ee805..da3bcf8d 100644 --- a/crates/vite_task_plan/src/context.rs +++ b/crates/vite_task_plan/src/context.rs @@ -3,7 +3,7 @@ use std::{ }; use vite_path::AbsolutePath; -use vite_task_graph::{IndexedTaskGraph, TaskNodeIndex, display::TaskDispay}; +use vite_task_graph::{IndexedTaskGraph, TaskNodeIndex, display::TaskDisplay}; use crate::{PlanCallbacks, path_env::prepend_path_env}; @@ -39,7 +39,7 @@ pub struct PlanContext<'a> { /// A human-readable frame in the task call stack. #[derive(Debug, Clone)] pub struct TaskCallStackFrameDisplay { - pub task_display: TaskDispay, + pub task_display: TaskDisplay, #[expect(dead_code)] // To be used in terminal error display pub command_span: Range, diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 86717768..7b49eab9 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -1,6 +1,6 @@ use std::env::JoinPathsError; -use vite_task_graph::display::TaskDispay; +use vite_task_graph::display::TaskDisplay; use crate::{ context::{PlanContext, TaskCallStackDisplay, TaskCycleError}, @@ -38,7 +38,7 @@ pub enum TaskPlanErrorKind { /// 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: TaskDispay, + task_display: TaskDisplay, #[source] join_paths_error: JoinPathsError, }, diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index cb99e19b..b32741d2 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -80,7 +80,7 @@ pub struct TaskExecution { /// The task index in the task graph pub task_node_index: TaskNodeIndex, - /// A task's command is splitted by `&&` and expanded into multiple execution items. + /// 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, @@ -108,7 +108,7 @@ pub enum LeafExecutionKind { InProcess(InProcessExecution), } -/// An execution item, from a splitted subcommand in a task's command (`item1 && item2 && ...`). +/// 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`. From 63640367e42b9fe982860cc1d832c01c2da52b2d Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 04:07:46 +0800 Subject: [PATCH 24/26] remove commented codes --- crates/vite_task_plan/src/lib.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index b32741d2..9247b55f 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -21,21 +21,6 @@ use vite_path::AbsolutePath; use vite_str::Str; use vite_task_graph::{TaskGraphLoadError, TaskNodeIndex, query::TaskQuery}; -/* -/// Where an execution originates from -#[derive(Debug)] -pub enum ExecutionOrigin { - /// the execution originates from the task graph (defined in `package.json` or `vite.config.*`) - /// - /// The precise location of this execution in the task graph can be inferred by - /// `ExecutionGraphNode.task_index` and index of `ExecutionItem` in `ExecutionGraphNode.items`. - TaskGraph, - - /// the command originates from an synthetic command, like `oxlint ...` synthesized from `vite lint` - Synthetic -} - */ - /// Resolved cache configuration for a spawn execution. #[derive(Debug)] pub struct ResolvedCacheConfig { @@ -48,10 +33,6 @@ pub struct ResolvedCacheConfig { /// like resolved environment variables, current working directory, and additional args from cli. #[derive(Debug)] pub struct SpawnExecution { - /* - /// Where this resolved command originates from - pub origin: ExecutionOrigin, - */ /// Resolved cache configuration for this execution. `None` means caching is disabled. pub resolved_cache_config: Option, From e47e5749c070806cd5f3d274519417d536a0956d Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 06:38:54 +0800 Subject: [PATCH 25/26] rename cycle to recursion --- crates/vite_task_plan/src/context.rs | 67 +++++----------------------- crates/vite_task_plan/src/error.rs | 4 +- crates/vite_task_plan/src/plan.rs | 6 +-- 3 files changed, 16 insertions(+), 61 deletions(-) diff --git a/crates/vite_task_plan/src/context.rs b/crates/vite_task_plan/src/context.rs index da3bcf8d..a15462bc 100644 --- a/crates/vite_task_plan/src/context.rs +++ b/crates/vite_task_plan/src/context.rs @@ -9,13 +9,11 @@ use crate::{PlanCallbacks, path_env::prepend_path_env}; #[derive(Debug, thiserror::Error)] #[error( - "Detected a cycle in task call stack, from the {0}th frame to the end", cycle_start + 1 + "Detected a recursion in task call stack: the last frame calls the {0}th frame", recursion_point + 1 )] -pub struct TaskCycleError { - /// The index in `task_call_stack` where the cycle starts - /// - /// The cycle ends at the end of `task_call_stack`. - cycle_start: usize, +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. @@ -93,12 +91,15 @@ impl<'a> PlanContext<'a> { } } - /// Check if adding the given task node index would create a cycle in the call stack. - pub fn check_cycle(&self, task_node_index: TaskNodeIndex) -> Result<(), TaskCycleError> { - if let Some(cycle_start) = + /// 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(TaskCycleError { cycle_start }); + return Err(TaskRecursionError { recursion_point: recursion_start }); } Ok(()) } @@ -139,49 +140,3 @@ impl<'a> PlanContext<'a> { } } } -// pub fn enter_package(&mut self, package_path: Arc) -> Result, PackageCycleError> { -// Ok(PlanContext { -// cwd: package_path, -// envs: Arc::clone(&self.envs), -// callbacks: self.callbacks, -// stack: self.stack, -// }) -// } - -// /// Create a new context with new frame. -// /// -// /// Returns `None` if the new frame already exists in the stack (to prevent infinite recursion). -// pub fn with_new_frame( -// &mut self, -// new_frame: PlanStackFrame, -// envs: impl Iterator, impl AsRef)>, -// cwd: Arc, -// f: impl FnOnce(PlanContext<'_>) -> R, -// ) -> Option { -// // IndexSet::insert returns `false` and doesn't touch the set if the item already exists. -// if !self.stack.insert(new_frame) { -// return None; -// } -// // Merge envs -// let mut new_envs: Option, Arc>> = None; -// for (key, value) in envs { -// // Clone on write -// new_envs -// .get_or_insert_with(|| self.envs.as_ref().clone()) -// .insert(Arc::from(key.as_ref()), Arc::from(value.as_ref())); -// } - -// let ret = f(PlanContext { -// cwd, -// envs: if let Some(new_envs) = new_envs { -// Arc::new(new_envs) -// } else { -// Arc::clone(&self.envs) -// }, -// callbacks: self.callbacks, -// stack: self.stack, -// }); -// self.stack.pop().expect("stack pop should succeed"); -// Some(ret) -// } -// } diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 7b49eab9..b656c232 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -3,7 +3,7 @@ use std::env::JoinPathsError; use vite_task_graph::display::TaskDisplay; use crate::{ - context::{PlanContext, TaskCallStackDisplay, TaskCycleError}, + context::{PlanContext, TaskCallStackDisplay, TaskRecursionError}, envs::ResolveEnvError, }; @@ -25,7 +25,7 @@ pub enum TaskPlanErrorKind { ), #[error(transparent)] - TaskCycleDetected(#[from] TaskCycleError), + TaskRecursionDetected(#[from] TaskRecursionError), #[error("Invalid vite task command")] ParsePlanRequestError { diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 42a4d2a5..5c6bf44f 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -24,10 +24,10 @@ async fn plan_task_as_execution_node( task_node_index: TaskNodeIndex, mut context: PlanContext<'_>, ) -> Result { - // Check for cycles in the task call stack. + // Check for recursions in the task call stack. context - .check_cycle(task_node_index) - .map_err(TaskPlanErrorKind::TaskCycleDetected) + .check_recursion(task_node_index) + .map_err(TaskPlanErrorKind::TaskRecursionDetected) .with_plan_context(&context)?; // Prepend {package_path}/node_modules/.bin to PATH From 2c023b021c84cb44ad33e4c76cf29d21a44c4c19 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 17 Dec 2025 06:51:06 +0800 Subject: [PATCH 26/26] add README for vite_task_plan --- crates/vite_task_plan/README.md | 71 +++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 crates/vite_task_plan/README.md 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