diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 29d65539e0..cb7cca623d 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -5,12 +5,20 @@ //! config like `run` without needing a Node.js runtime. use oxc_allocator::Allocator; -use oxc_ast::ast::{Expression, ObjectPropertyKind, Program, Statement}; +use oxc_ast::ast::{ + BindingPattern, Expression, ImportDeclarationSpecifier, ImportOrExportKind, ObjectPropertyKind, + Program, Statement, VariableDeclarationKind, +}; use oxc_parser::Parser; use oxc_span::SourceType; use rustc_hash::FxHashMap; use vite_path::AbsolutePath; +/// The package that exports the `defineConfig` helper static extraction trusts. +const VITE_PLUS_PACKAGE: &str = "vite-plus"; +/// The name of the config helper static extraction trusts. +const DEFINE_CONFIG: &str = "defineConfig"; + /// The result of statically analyzing a single config field's value. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FieldValue { @@ -139,16 +147,20 @@ fn parse_js_ts_config(source: &str, extension: &str) -> FieldMap { /// Find the config object in a parsed program and extract its fields. /// /// Searches for the config value in the following patterns (in order): -/// 1. `export default defineConfig({ ... })` +/// 1. `export default defineConfig({ ... })` with `defineConfig` trusted from `vite-plus` /// 2. `export default { ... }` -/// 3. `module.exports = defineConfig({ ... })` +/// 3. `module.exports = defineConfig({ ... })` with `defineConfig` trusted from `vite-plus` /// 4. `module.exports = { ... }` fn extract_config_fields(program: &Program<'_>) -> FieldMap { + let has_trusted_define_config_binding = program.body.iter().any(|stmt| { + is_trusted_define_config_import(stmt) || has_trusted_define_config_cjs_binding(stmt) + }); + for stmt in &program.body { // ESM: export default ... if let Statement::ExportDefaultDeclaration(decl) = stmt { if let Some(expr) = decl.declaration.as_expression() { - return extract_config_from_expr(expr); + return extract_config_from_expr(expr, has_trusted_define_config_binding); } // export default class/function — not analyzable return FieldMap::unanalyzable(); @@ -161,7 +173,7 @@ fn extract_config_fields(program: &Program<'_>) -> FieldMap { m.object().is_specific_id("module") && m.static_property_name() == Some("exports") }) { - return extract_config_from_expr(&assign.right); + return extract_config_from_expr(&assign.right, has_trusted_define_config_binding); } } @@ -175,13 +187,19 @@ fn extract_config_fields(program: &Program<'_>) -> FieldMap { /// - `defineConfig(function() { return { ... }; })` → extract from return statement /// - `{ ... }` → extract directly /// - anything else → not analyzable -fn extract_config_from_expr(expr: &Expression<'_>) -> FieldMap { +fn extract_config_from_expr( + expr: &Expression<'_>, + has_trusted_define_config_binding: bool, +) -> FieldMap { let expr = expr.without_parentheses(); match expr { Expression::CallExpression(call) => { if !call.callee.is_specific_id("defineConfig") { return FieldMap::unanalyzable(); } + if !has_trusted_define_config_binding { + return FieldMap::unanalyzable(); + } let Some(first_arg) = call.arguments.first() else { return FieldMap::unanalyzable(); }; @@ -246,6 +264,74 @@ fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> F FieldMap::unanalyzable() } +fn is_trusted_define_config_import(stmt: &Statement<'_>) -> bool { + let Statement::ImportDeclaration(import_decl) = stmt else { + return false; + }; + if import_decl.import_kind != ImportOrExportKind::Value + || import_decl.source.value != VITE_PLUS_PACKAGE + { + return false; + } + import_decl.specifiers.as_ref().is_some_and(|specifiers| { + specifiers.iter().any(|specifier| { + let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { + return false; + }; + specifier.import_kind == ImportOrExportKind::Value + && specifier.local.name == DEFINE_CONFIG + && specifier.imported.name() == DEFINE_CONFIG + }) + }) +} + +fn has_trusted_define_config_cjs_binding(stmt: &Statement<'_>) -> bool { + match stmt { + Statement::VariableDeclaration(decl) => { + decl.kind == VariableDeclarationKind::Const + && decl.declarations.iter().any(|declarator| { + declarator.init.as_ref().is_some_and(|init| match &declarator.id { + BindingPattern::BindingIdentifier(id) => { + id.name == DEFINE_CONFIG && is_require_vite_plus_define_config(init) + } + BindingPattern::ObjectPattern(pattern) => { + is_require_vite_plus(init) + && pattern.properties.iter().any(|prop| { + !prop.computed + && prop + .key + .static_name() + .is_some_and(|key| key == DEFINE_CONFIG) + && matches!( + &prop.value, + BindingPattern::BindingIdentifier(id) + if id.name == DEFINE_CONFIG + ) + }) + } + _ => false, + }) + }) + } + _ => false, + } +} + +fn is_require_vite_plus_define_config(expr: &Expression<'_>) -> bool { + expr.without_parentheses().as_member_expression().is_some_and(|member| { + member.static_property_name() == Some(DEFINE_CONFIG) + && is_require_vite_plus(member.object()) + }) +} + +fn is_require_vite_plus(expr: &Expression<'_>) -> bool { + matches!( + expr.without_parentheses(), + Expression::CallExpression(call) + if call.common_js_require().is_some_and(|source| source.value == VITE_PLUS_PACKAGE) + ) +} + /// Count `return` statements recursively in a slice of statements. /// Does not descend into nested function/arrow expressions (they have their own returns). fn count_returns_in_stmts(stmts: &[Statement<'_>]) -> usize { @@ -529,6 +615,19 @@ mod tests { assert_json(&result, "num", serde_json::json!(42)); } + #[test] + fn plain_export_default_object_ignores_unrelated_define_config_import() { + let result = parse( + r" + import { defineConfig } from 'vite'; + export default { + run: { cacheScripts: true }, + }; + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + #[test] fn export_default_empty_object() { let result = parse("export default {}"); @@ -552,6 +651,32 @@ mod tests { assert_json(&result, "lint", serde_json::json!({ "plugins": ["a"] })); } + #[test] + fn define_config_import_from_other_package_is_non_static() { + let result = parse( + r" + import { defineConfig } from 'vite'; + export default defineConfig({ + run: { cacheScripts: true }, + }); + ", + ); + assert_non_static(&result, "run"); + } + + #[test] + fn define_config_type_import_from_vite_plus_is_non_static() { + let result = parse( + r" + import type { defineConfig } from 'vite-plus'; + export default defineConfig({ + run: { cacheScripts: true }, + }); + ", + ); + assert_non_static(&result, "run"); + } + // ── module.exports = { ... } ─────────────────────────────────────── #[test] @@ -561,7 +686,7 @@ mod tests { } #[test] - fn module_exports_define_config() { + fn module_exports_define_config_destructured_require() { let result = parse_js_ts_config( r" const { defineConfig } = require('vite-plus'); @@ -574,6 +699,20 @@ mod tests { assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } + #[test] + fn module_exports_define_config_member_require() { + let result = parse_js_ts_config( + r" + const defineConfig = require('vite-plus').defineConfig; + module.exports = defineConfig({ + run: { cacheScripts: true }, + }); + ", + "cjs", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + #[test] fn module_exports_non_object() { assert_non_static(&parse_js_ts_config("module.exports = 42;", "cjs"), "run"); @@ -944,6 +1083,8 @@ mod tests { fn define_config_arrow_block_body() { let result = parse( r" + import { defineConfig } from 'vite-plus'; + export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); return { @@ -961,6 +1102,8 @@ mod tests { fn define_config_arrow_expression_body() { let result = parse( r" + import { defineConfig } from 'vite-plus'; + export default defineConfig(() => ({ run: { cacheScripts: true }, build: { outDir: 'dist' }, @@ -975,6 +1118,8 @@ mod tests { fn define_config_function_expression() { let result = parse( r" + import { defineConfig } from 'vite-plus'; + export default defineConfig(function() { return { run: { cacheScripts: true }, diff --git a/packages/cli/snap-tests/run-config-custom-define-config/define-config.ts b/packages/cli/snap-tests/run-config-custom-define-config/define-config.ts new file mode 100644 index 0000000000..cd81b1fe9b --- /dev/null +++ b/packages/cli/snap-tests/run-config-custom-define-config/define-config.ts @@ -0,0 +1,12 @@ +export function defineConfig(_config: unknown) { + return { + run: { + tasks: { + selected: { + command: 'node runtime.js', + dependsOn: [], + }, + }, + }, + }; +} diff --git a/packages/cli/snap-tests/run-config-custom-define-config/package.json b/packages/cli/snap-tests/run-config-custom-define-config/package.json new file mode 100644 index 0000000000..cb6912a458 --- /dev/null +++ b/packages/cli/snap-tests/run-config-custom-define-config/package.json @@ -0,0 +1,4 @@ +{ + "name": "run-config-custom-define-config", + "private": true +} diff --git a/packages/cli/snap-tests/run-config-custom-define-config/runtime.js b/packages/cli/snap-tests/run-config-custom-define-config/runtime.js new file mode 100644 index 0000000000..e684430508 --- /dev/null +++ b/packages/cli/snap-tests/run-config-custom-define-config/runtime.js @@ -0,0 +1 @@ +console.log('runtime defineConfig result'); diff --git a/packages/cli/snap-tests/run-config-custom-define-config/snap.txt b/packages/cli/snap-tests/run-config-custom-define-config/snap.txt new file mode 100644 index 0000000000..397862db9f --- /dev/null +++ b/packages/cli/snap-tests/run-config-custom-define-config/snap.txt @@ -0,0 +1,4 @@ +> vp run selected # defineConfig from a local module must use runtime config resolution +$ node runtime.js +runtime defineConfig result + diff --git a/packages/cli/snap-tests/run-config-custom-define-config/static.js b/packages/cli/snap-tests/run-config-custom-define-config/static.js new file mode 100644 index 0000000000..90f289f6bc --- /dev/null +++ b/packages/cli/snap-tests/run-config-custom-define-config/static.js @@ -0,0 +1 @@ +console.log('statically extracted config'); diff --git a/packages/cli/snap-tests/run-config-custom-define-config/steps.json b/packages/cli/snap-tests/run-config-custom-define-config/steps.json new file mode 100644 index 0000000000..5b0d9957c0 --- /dev/null +++ b/packages/cli/snap-tests/run-config-custom-define-config/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "vp run selected # defineConfig from a local module must use runtime config resolution" + ] +} diff --git a/packages/cli/snap-tests/run-config-custom-define-config/vite.config.ts b/packages/cli/snap-tests/run-config-custom-define-config/vite.config.ts new file mode 100644 index 0000000000..4725c9d0e7 --- /dev/null +++ b/packages/cli/snap-tests/run-config-custom-define-config/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from './define-config'; + +export default defineConfig({ + run: { + tasks: { + selected: { + command: 'node static.js', + dependsOn: [], + }, + }, + }, +});