Skip to content
Merged
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
159 changes: 152 additions & 7 deletions crates/vite_static_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
}

Expand All @@ -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();
};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {}");
Expand All @@ -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]
Expand All @@ -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');
Expand All @@ -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");
Expand Down Expand Up @@ -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 {
Expand All @@ -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' },
Expand All @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function defineConfig(_config: unknown) {
return {
run: {
tasks: {
selected: {
command: 'node runtime.js',
dependsOn: [],
},
},
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "run-config-custom-define-config",
"private": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('runtime defineConfig result');
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
> vp run selected # defineConfig from a local module must use runtime config resolution
$ node runtime.js
runtime defineConfig result

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('statically extracted config');
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"commands": [
"vp run selected # defineConfig from a local module must use runtime config resolution"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from './define-config';

export default defineConfig({
run: {
tasks: {
selected: {
command: 'node static.js',
dependsOn: [],
},
},
},
});
Loading