Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "vite-config-task-from-plugin-test",
"private": true
}
Original file line number Diff line number Diff line change
@@ -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"],
]
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
@@ -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',
},
},
},
};
},
},
],
};
23 changes: 13 additions & 10 deletions crates/vp_static_config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,19 @@ Searches for config files in the same order as Vite's

## Return type

`resolve_static_config` returns `Option<FxHashMap<Box<str>, 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

Expand Down
78 changes: 64 additions & 14 deletions crates/vp_static_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<str>, FieldValue>,
open: bool,
Expand All @@ -64,24 +67,35 @@ 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
/// last spread/computed key, or `Some(NonStatic)` for any other key (it
/// may exist in the spread).
#[must_use]
pub fn get(&self, key: &str) -> Option<FieldValue> {
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<FieldValue> {
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<FieldValue> {
self.map.get(key).cloned()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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 }));
}

Expand All @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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");
}

Expand Down
Loading