Skip to content

Commit 0ad7fbb

Browse files
authored
feat: task plan system (#59)
# Add vite_task_plan crate for task execution planning This PR adds a new `vite_task_plan` crate that handles planning task executions from the task graph. It introduces several key components: - `ExecutionPlan`: Represents how tasks should be executed, with support for both spawned processes and in-process executions - `ResolvedEnvs`: Handles environment variable resolution with proper fingerprinting for caching - `PlanContext`: Manages the execution context including working directory, environment variables, and call stack - Error handling with detailed task call stack information: ``` Error: Failed to load the task graph Caused by: Failed to lookup dependency 'lib#build' for task 'app#build' Caused by Package name 'lib' is ambiguous among multiple packages: - 'packages/lib1' - 'packages/lib2' ``` - Detect recursions: ```jsonc { // direct recursion "build": "vite run build", // indirect recursion "ready": "vite run lint && vite run format", "format": "vite run ready && vite fmt", } ```
1 parent e9b020d commit 0ad7fbb

24 files changed

+1853
-102
lines changed

Cargo.lock

Lines changed: 24 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ vite_glob = { path = "crates/vite_glob" }
121121
vite_path = { path = "crates/vite_path" }
122122
vite_shell = { path = "crates/vite_shell" }
123123
vite_str = { path = "crates/vite_str" }
124+
vite_task_graph = { path = "crates/vite_task_graph" }
124125
vite_workspace = { path = "crates/vite_workspace" }
125126
wax = "0.6.0"
126127
which = "8.0.0"

crates/vite_str/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::{
55
ops::Deref,
66
path::Path,
77
str::from_utf8,
8+
sync::Arc,
89
};
910

1011
use bincode::{
@@ -158,6 +159,12 @@ impl From<CompactString> for Str {
158159
}
159160
}
160161

162+
impl From<Str> for Arc<str> {
163+
fn from(value: Str) -> Self {
164+
Arc::from(value.as_str())
165+
}
166+
}
167+
161168
impl PartialEq<&str> for Str {
162169
fn eq(&self, other: &&str) -> bool {
163170
self.0 == other
Lines changed: 125 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
mod user;
1+
pub mod user;
22

3-
use std::collections::HashSet;
3+
use std::{collections::HashSet, sync::Arc};
44

55
use monostate::MustBe;
66
pub use user::{UserCacheConfig, UserConfigFile, UserTaskConfig};
7-
use vite_path::{AbsolutePath, AbsolutePathBuf};
7+
use vite_path::AbsolutePath;
88
use vite_str::Str;
99

10+
use crate::config::user::UserTaskOptions;
11+
1012
/// Task configuration resolved from `package.json` scripts and/or `vite.config.ts` tasks,
1113
/// without considering external factors like additional args from cli or environment variables.
1214
///
@@ -16,29 +18,64 @@ use vite_str::Str;
1618
/// For example, `cwd` is resolved to absolute ones (no external factor can change it),
1719
/// but `command` is not parsed into program and args yet because environment variables in it may need to be expanded.
1820
///
19-
/// `depends_on` is not included here because it's represented in the task graph.
21+
/// `depends_on` is not included here because it's represented by the edges of the task graph.
2022
#[derive(Debug)]
21-
pub struct ResolvedUserTaskConfig {
22-
/// The command to run for this task
23+
pub struct ResolvedTaskConfig {
24+
/// The command to run for this task, as a raw string.
25+
///
26+
/// The command may contain environment variables that need to be expanded later.
2327
pub command: Str,
2428

25-
/// The working directory for the task
26-
pub cwd: AbsolutePathBuf,
29+
pub resolved_options: ResolvedTaskOptions,
30+
}
2731

32+
#[derive(Debug)]
33+
pub struct ResolvedTaskOptions {
34+
/// The working directory for the task
35+
pub cwd: Arc<AbsolutePath>,
2836
/// Cache-related config. None means caching is disabled.
2937
pub cache_config: Option<CacheConfig>,
3038
}
3139

40+
impl ResolvedTaskOptions {
41+
/// Resolves from user-defined options and the directory path where the options are defined.
42+
pub fn resolve(user_options: UserTaskOptions, dir: &Arc<AbsolutePath>) -> Self {
43+
let cwd: Arc<AbsolutePath> = if user_options.cwd_relative_to_package.as_str().is_empty() {
44+
Arc::clone(dir)
45+
} else {
46+
dir.join(user_options.cwd_relative_to_package).into()
47+
};
48+
let cache_config = match user_options.cache_config {
49+
UserCacheConfig::Disabled { cache: MustBe!(false) } => None,
50+
UserCacheConfig::Enabled { cache: MustBe!(true), envs, mut pass_through_envs } => {
51+
pass_through_envs.extend(DEFAULT_PASSTHROUGH_ENVS.iter().copied().map(Str::from));
52+
Some(CacheConfig {
53+
env_config: EnvConfig {
54+
fingerprinted_envs: envs.into_iter().collect(),
55+
pass_through_envs: pass_through_envs.into(),
56+
},
57+
})
58+
}
59+
};
60+
Self { cwd, cache_config }
61+
}
62+
}
63+
3264
#[derive(Debug)]
3365
pub struct CacheConfig {
66+
pub env_config: EnvConfig,
67+
}
68+
69+
#[derive(Debug)]
70+
pub struct EnvConfig {
3471
/// environment variable names to be fingerprinted and passed to the task, with defaults populated
35-
pub envs: HashSet<Str>,
72+
pub fingerprinted_envs: HashSet<Str>,
3673
/// environment variable names to be passed to the task without fingerprinting, with defaults populated
37-
pub pass_through_envs: HashSet<Str>,
74+
pub pass_through_envs: Arc<[Str]>,
3875
}
3976

4077
#[derive(Debug, thiserror::Error)]
41-
pub enum ResolveTaskError {
78+
pub enum ResolveTaskConfigError {
4279
/// Both package.json script and vite.config.* task define commands for the task
4380
#[error("Both package.json script and vite.config.* task define commands for the task")]
4481
CommandConflict,
@@ -48,41 +85,95 @@ pub enum ResolveTaskError {
4885
NoCommand,
4986
}
5087

51-
impl ResolvedUserTaskConfig {
88+
impl ResolvedTaskConfig {
89+
/// Resolve from package.json script only
5290
pub fn resolve_package_json_script(
53-
package_dir: &AbsolutePath,
91+
package_dir: &Arc<AbsolutePath>,
5492
package_json_script: &str,
5593
) -> Self {
56-
Self::resolve(
57-
UserTaskConfig::package_json_script_default(),
58-
package_dir,
59-
Some(package_json_script),
60-
)
61-
.expect("Command conflict/missing for package.json script should never happen")
94+
Self {
95+
command: package_json_script.into(),
96+
resolved_options: ResolvedTaskOptions::resolve(UserTaskOptions::default(), package_dir),
97+
}
6298
}
6399

64100
/// Resolves from user config, package dir, and package.json script (if any).
65101
pub fn resolve(
66102
user_config: UserTaskConfig,
67-
package_dir: &AbsolutePath,
103+
package_dir: &Arc<AbsolutePath>,
68104
package_json_script: Option<&str>,
69-
) -> Result<Self, ResolveTaskError> {
105+
) -> Result<Self, ResolveTaskConfigError> {
70106
let command = match (&user_config.command, package_json_script) {
71-
(Some(_), Some(_)) => return Err(ResolveTaskError::CommandConflict),
72-
(None, None) => return Err(ResolveTaskError::NoCommand),
107+
(Some(_), Some(_)) => return Err(ResolveTaskConfigError::CommandConflict),
108+
(None, None) => return Err(ResolveTaskConfigError::NoCommand),
73109
(Some(cmd), None) => cmd.as_ref(),
74110
(None, Some(script)) => script,
75111
};
76-
let cwd = package_dir.join(user_config.cwd_relative_to_package);
77-
let cache_config = match user_config.cache_config {
78-
UserCacheConfig::Disabled { cache: MustBe!(false) } => None,
79-
UserCacheConfig::Enabled { cache: MustBe!(true), envs, pass_through_envs } => {
80-
Some(CacheConfig {
81-
envs: envs.into_iter().collect(),
82-
pass_through_envs: pass_through_envs.into_iter().collect(),
83-
})
84-
}
85-
};
86-
Ok(Self { command: command.into(), cwd, cache_config })
112+
Ok(Self {
113+
command: command.into(),
114+
resolved_options: ResolvedTaskOptions::resolve(user_config.options, package_dir),
115+
})
87116
}
88117
}
118+
119+
// Exact matches for common environment variables
120+
// Referenced from Turborepo's implementation:
121+
// https://github.com/vercel/turborepo/blob/26d309f073ca3ac054109ba0c29c7e230e7caac3/crates/turborepo-lib/src/task_hash.rs#L439
122+
const DEFAULT_PASSTHROUGH_ENVS: &[&str] = &[
123+
// System and shell
124+
"HOME",
125+
"USER",
126+
"TZ",
127+
"LANG",
128+
"SHELL",
129+
"PWD",
130+
"PATH",
131+
// CI/CD environments
132+
"CI",
133+
// Node.js specific
134+
"NODE_OPTIONS",
135+
"COREPACK_HOME",
136+
"NPM_CONFIG_STORE_DIR",
137+
"PNPM_HOME",
138+
// Library paths
139+
"LD_LIBRARY_PATH",
140+
"DYLD_FALLBACK_LIBRARY_PATH",
141+
"LIBPATH",
142+
// Terminal/display
143+
"COLORTERM",
144+
"TERM",
145+
"TERM_PROGRAM",
146+
"DISPLAY",
147+
"FORCE_COLOR",
148+
"NO_COLOR",
149+
// Temporary directories
150+
"TMP",
151+
"TEMP",
152+
// Vercel specific
153+
"VERCEL",
154+
"VERCEL_*",
155+
"NEXT_*",
156+
"USE_OUTPUT_FOR_EDGE_FUNCTIONS",
157+
"NOW_BUILDER",
158+
// Windows specific
159+
"APPDATA",
160+
"PROGRAMDATA",
161+
"SYSTEMROOT",
162+
"SYSTEMDRIVE",
163+
"USERPROFILE",
164+
"HOMEDRIVE",
165+
"HOMEPATH",
166+
// IDE specific (exact matches)
167+
"ELECTRON_RUN_AS_NODE",
168+
"JB_INTERPRETER",
169+
"_JETBRAINS_TEST_RUNNER_RUN_SCOPE_TYPE",
170+
"JB_IDE_*",
171+
// VSCode specific
172+
"VSCODE_*",
173+
// Docker specific
174+
"DOCKER_*",
175+
"BUILDKIT_*",
176+
"COMPOSE_*",
177+
// Token patterns
178+
"*_TOKEN",
179+
];

crates/vite_task_graph/src/config/user.rs

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub enum UserCacheConfig {
2424

2525
/// Environment variable names to be passed to the task without fingerprinting.
2626
#[serde(default)] // default to empty if omitted
27-
pass_through_envs: Box<[Str]>,
27+
pass_through_envs: Vec<Str>,
2828
},
2929
/// Cache is disabled
3030
Disabled {
@@ -33,13 +33,10 @@ pub enum UserCacheConfig {
3333
},
3434
}
3535

36-
/// Task configuration defined by user in `vite.config.*`
36+
/// Options for user-defined tasks in `vite.config.*`, excluding the command.
3737
#[derive(Debug, Deserialize, PartialEq, Eq)]
3838
#[serde(rename_all = "camelCase")]
39-
pub struct UserTaskConfig {
40-
/// If None, the script from `package.json` with the same name will be used
41-
pub command: Option<Box<str>>,
42-
39+
pub struct UserTaskOptions {
4340
/// The working directory for the task, relative to the package root (not workspace root).
4441
#[serde(default)] // default to empty if omitted
4542
#[serde(rename = "cwd")]
@@ -54,22 +51,36 @@ pub struct UserTaskConfig {
5451
pub cache_config: UserCacheConfig,
5552
}
5653

57-
impl UserTaskConfig {
58-
/// The default user task config for package.json scripts.
59-
pub fn package_json_script_default() -> Self {
54+
impl Default for UserTaskOptions {
55+
/// The default user task options for package.json scripts.
56+
fn default() -> Self {
6057
Self {
61-
command: None,
58+
// Runs in the package root
6259
cwd_relative_to_package: RelativePathBuf::default(),
60+
// No dependencies
6361
depends_on: Arc::new([]),
62+
// Caching enabled with no fingerprinted envs
6463
cache_config: UserCacheConfig::Enabled {
6564
cache: MustBe!(true),
6665
envs: Box::new([]),
67-
pass_through_envs: Box::new([]),
66+
pass_through_envs: Vec::new(),
6867
},
6968
}
7069
}
7170
}
7271

72+
/// Full user-defined task configuration in `vite.config.*`, including the command and options.
73+
#[derive(Debug, Deserialize, PartialEq, Eq)]
74+
#[serde(rename_all = "camelCase")]
75+
pub struct UserTaskConfig {
76+
/// If None, the script from `package.json` with the same name will be used
77+
pub command: Option<Box<str>>,
78+
79+
/// Fields other than the command
80+
#[serde(flatten)]
81+
pub options: UserTaskOptions,
82+
}
83+
7384
/// User configuration file structure for `vite.config.*`
7485
#[derive(Debug, Deserialize)]
7586
pub struct UserConfigFile {
@@ -90,13 +101,8 @@ mod tests {
90101
user_config,
91102
UserTaskConfig {
92103
command: None,
93-
cwd_relative_to_package: "".try_into().unwrap(),
94-
depends_on: Default::default(),
95-
cache_config: UserCacheConfig::Enabled {
96-
cache: MustBe!(true),
97-
envs: Default::default(),
98-
pass_through_envs: Default::default(),
99-
},
104+
// A empty task config (`{}`) should be equivalent to not specifying any config at all (just package.json script)
105+
options: UserTaskOptions::default(),
100106
}
101107
);
102108
}
@@ -107,7 +113,7 @@ mod tests {
107113
"cwd": "src"
108114
});
109115
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
110-
assert_eq!(user_config.cwd_relative_to_package.as_str(), "src");
116+
assert_eq!(user_config.options.cwd_relative_to_package.as_str(), "src");
111117
}
112118

113119
#[test]
@@ -116,7 +122,10 @@ mod tests {
116122
"cache": false
117123
});
118124
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
119-
assert_eq!(user_config.cache_config, UserCacheConfig::Disabled { cache: MustBe!(false) });
125+
assert_eq!(
126+
user_config.options.cache_config,
127+
UserCacheConfig::Disabled { cache: MustBe!(false) }
128+
);
120129
}
121130

122131
#[test]

0 commit comments

Comments
 (0)