diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/package.json new file mode 100644 index 0000000000..2e126e5e5d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-config-task-from-plugin-test", + "private": true +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots.toml new file mode 100644 index 0000000000..ba8cea58db --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots.toml @@ -0,0 +1,14 @@ +[[case]] +name = "task_added_by_plugin" +vp = "local" +comment = "A plugin config hook can provide a task when the config has no run field." +steps = [["vp", "run", "build:prod"]] + +[[case]] +name = "task_overridden_by_plugin" +vp = "local" +comment = "A plugin config hook can override a statically analyzable task." +steps = [ + { argv = ["vpt", "replace-file-content", "vite.config.ts", "export default {", "export default { run: { tasks: { 'build:prod': { command: 'vpt exit 1' } } },"], snapshot = false }, + ["vp", "run", "build:prod"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_added_by_plugin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_added_by_plugin.md new file mode 100644 index 0000000000..afd1b23437 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_added_by_plugin.md @@ -0,0 +1,10 @@ +# task_added_by_plugin + +A plugin config hook can provide a task when the config has no run field. + +## `vp run build:prod` + +``` +$ vpt print build:prod from plugin config hook +build:prod from plugin config hook +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_overridden_by_plugin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_overridden_by_plugin.md new file mode 100644 index 0000000000..48ae163518 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_overridden_by_plugin.md @@ -0,0 +1,13 @@ +# task_overridden_by_plugin + +A plugin config hook can override a statically analyzable task. + +## `vpt replace-file-content vite.config.ts 'export default {' 'export default { run: { tasks: { '\''build:prod'\'': { command: '\''vpt exit 1'\'' } } },'` + + +## `vp run build:prod` + +``` +$ vpt print build:prod from plugin config hook +build:prod from plugin config hook +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/vite.config.ts new file mode 100644 index 0000000000..c7c7df34bc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/vite.config.ts @@ -0,0 +1,18 @@ +export default { + plugins: [ + { + name: 'task-plugin', + config() { + return { + run: { + tasks: { + 'build:prod': { + command: 'vpt print build:prod from plugin config hook', + }, + }, + }, + }; + }, + }, + ], +}; diff --git a/crates/vp_static_config/README.md b/crates/vp_static_config/README.md index 950649567d..1112312e56 100644 --- a/crates/vp_static_config/README.md +++ b/crates/vp_static_config/README.md @@ -38,16 +38,19 @@ Searches for config files in the same order as Vite's ## Return type -`resolve_static_config` returns `Option, FieldValue>>`: - -- **`None`** — config is not statically analyzable (no config file, parse error, no - `export default`/`module.exports`, or the exported value is not an object literal). - Caller should fall back to runtime evaluation (e.g. NAPI). -- **`Some(map)`** — config object was successfully located: - - `FieldValue::Json(value)` — field value extracted as pure JSON - - `FieldValue::NonStatic` — field exists but contains non-JSON expressions - (function calls, variables, template literals with interpolation, etc.) - - Key absent — field does not exist in the config object +`resolve_static_config` returns a `FieldMap`. `FieldMap::get` answers what a field is at runtime: + +- `Some(FieldValue::Json(value))` — field value extracted as pure JSON +- `Some(FieldValue::NonStatic)` — field contains non-JSON expressions, may come from a spread or + computed key, or the config is not statically analyzable. Caller should fall back to runtime + evaluation (e.g. NAPI). +- `None` — field does not exist in the config object + +When `plugins` is non-static, or may exist in a spread, `get` returns `NonStatic` for every field: +plugin `config` hooks can add or override any field at runtime. + +`FieldMap::get_declared` only reports fields written in the config object, ignoring spreads and +plugins. Use it for heuristics where a field that merely might exist must not change behavior. ## Limitations diff --git a/crates/vp_static_config/src/lib.rs b/crates/vp_static_config/src/lib.rs index d79c2d1a06..6abf9d8af6 100644 --- a/crates/vp_static_config/src/lib.rs +++ b/crates/vp_static_config/src/lib.rs @@ -46,6 +46,9 @@ pub enum FieldValue { /// values). Absent keys may exist via the spread and are treated as /// [`FieldValue::NonStatic`] by [`FieldMap::get`], but not by /// [`FieldMap::get_declared`]. +/// +/// Either way, [`FieldMap::get`] treats every field as +/// [`FieldValue::NonStatic`] when `plugins` may be non-static. pub struct FieldMap { map: FxHashMap, FieldValue>, open: bool, @@ -64,8 +67,11 @@ impl FieldMap { Self { map: FxHashMap::default(), open: false } } - /// Look up a field by name. + /// Look up a field's runtime value. /// + /// - non-static `plugins` (including ones that may exist in a spread): + /// returns `Some(NonStatic)` for every key, because plugin `config` + /// hooks can add or override any field. /// - closed map: returns the stored value, or `None` if the field is /// definitively absent. /// - open map: returns the stored value if explicitly declared after the @@ -73,15 +79,23 @@ impl FieldMap { /// may exist in the spread). #[must_use] pub fn get(&self, key: &str) -> Option { + match self.get_ignoring_plugins("plugins") { + Some(FieldValue::NonStatic) => Some(FieldValue::NonStatic), + _ => self.get_ignoring_plugins(key), + } + } + + fn get_ignoring_plugins(&self, key: &str) -> Option { self.map.get(key).cloned().or_else(|| self.open.then_some(FieldValue::NonStatic)) } /// Like [`get`](Self::get), but only for fields the config explicitly /// declares. Unlike `get`, an open map (spread, computed keys, or an /// unanalyzable config) does NOT report undeclared keys as `NonStatic`: - /// they return `None`. Use this when a merely-possible field must not - /// change behavior (e.g. `defaultPackage`, where erroring on every - /// unanalyzable config would break unrelated commands). + /// they return `None`. Plugins do not affect the result either. Use this + /// when a merely-possible field must not change behavior (e.g. + /// `defaultPackage`, where erroring on every unanalyzable config would + /// break unrelated commands). #[must_use] pub fn get_declared(&self, key: &str) -> Option { self.map.get(key).cloned() @@ -426,8 +440,8 @@ fn count_returns_in_stmt(stmt: &Statement<'_>) -> usize { /// Fields declared after the last spread/computed key are still extractable: /// /// ```js -/// { a: 1, ...x, b: 2 } // open{ b: Json(2) }; get("a") = NonStatic, get("b") = Json(2) -/// { a: 1, [k]: 2, b: 3 } // open{ b: Json(3) }; get("a") = NonStatic, get("b") = Json(3) +/// { a: 1, ...x, b: 2 } // open{ b: Json(2) }; get_declared("a") = None, get_declared("b") = Json(2) +/// { a: 1, [k]: 2, b: 3 } // open{ b: Json(3) }; get_declared("a") = None, get_declared("b") = Json(3) /// { a: 1, b: 2 } // closed{ a: Json(1), b: Json(2) }; get("c") = None /// ``` fn extract_object_fields(obj: &oxc_ast::ast::ObjectExpression<'_>) -> FieldMap { @@ -586,6 +600,11 @@ mod tests { assert_eq!(map.get_declared("defaultPackage"), None); } + /// Shorthand for asserting a field is declared as JSON, ignoring spreads and plugins. + fn assert_declared_json(map: &FieldMap, key: &str, expected: serde_json::Value) { + assert_eq!(map.get_declared(key), Some(FieldValue::Json(expected))); + } + /// Shorthand for asserting a field is `NonStatic`. fn assert_non_static(map: &FieldMap, key: &str) { assert_eq!( @@ -947,10 +966,32 @@ mod tests { plugins: [myPlugin()], }", ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); assert_non_static(&result, "plugins"); } + #[test] + fn non_static_plugins_make_every_field_non_static() { + // Plugin `config` hooks can add or override any field. + let result = parse( + r"export default { + run: { cacheScripts: true }, + plugins: [myPlugin()], + }", + ); + assert_non_static(&result, "run"); + assert_non_static(&result, "lint"); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn static_plugins_keep_fields_static() { + // JSON values cannot contain hooks, so a static `plugins` field changes nothing. + let result = parse(r"export default { run: { cacheScripts: true }, plugins: [] }"); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert!(result.get("lint").is_none()); + } + #[test] fn non_static_identifier_values() { let result = parse( @@ -1005,7 +1046,7 @@ mod tests { ", ); assert_non_static(&result, "x"); - assert_json(&result, "b", serde_json::json!("ok")); + assert_declared_json(&result, "b", serde_json::json!("ok")); } #[test] @@ -1021,7 +1062,7 @@ mod tests { assert_non_static(&result, "a"); assert_non_static(&result, "run"); assert_non_static(&result, "x"); - assert_json(&result, "b", serde_json::json!("ok")); + assert_declared_json(&result, "b", serde_json::json!("ok")); } #[test] @@ -1038,13 +1079,22 @@ mod tests { #[test] fn spread_then_explicit_run() { - // run is explicitly declared after the spread and is still extractable as Json. + // The spread may contain plugins, so `run` is only static once `plugins` is declared after it. let result = parse( r" const base = { plugins: [] }; export default { ...base, run: { cacheScripts: true } } ", ); + assert_non_static(&result, "run"); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + + let result = parse( + r" + const base = { plugins: [] }; + export default { ...base, plugins: [], run: { cacheScripts: true } } + ", + ); assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } @@ -1065,7 +1115,7 @@ mod tests { ", ); assert_non_static(&result, "dynamic"); - assert_json(&result, "plain", serde_json::json!("ok")); + assert_declared_json(&result, "plain", serde_json::json!("ok")); } #[test] @@ -1080,7 +1130,7 @@ mod tests { ); assert_non_static(&result, "a"); assert_non_static(&result, "run"); - assert_json(&result, "b", serde_json::json!(2)); + assert_declared_json(&result, "b", serde_json::json!(2)); } #[test] @@ -1210,7 +1260,7 @@ mod tests { }); ", ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); assert_non_static(&result, "plugins"); } @@ -1244,7 +1294,7 @@ mod tests { }); ", ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); assert_non_static(&result, "plugins"); }