From 4a269856db0d0eedc082725b7504386f150c889e Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 01:04:17 +0800 Subject: [PATCH 01/14] feat(config): add static vite config extraction to avoid NAPI for `run` config Add a new `vite_static_config` crate that uses oxc_parser to statically extract JSON-serializable fields from vite.config.* files without needing a Node.js runtime. The `VitePlusConfigLoader` now tries static extraction first for the `run` config and falls back to NAPI only when needed. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 13 + Cargo.toml | 1 + crates/vite_static_config/Cargo.toml | 21 + crates/vite_static_config/src/lib.rs | 773 +++++++++++++++++++++++++++ packages/cli/binding/Cargo.toml | 1 + packages/cli/binding/src/cli.rs | 12 + 6 files changed, 821 insertions(+) create mode 100644 crates/vite_static_config/Cargo.toml create mode 100644 crates/vite_static_config/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c3a6094eb0..359258fb67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7191,6 +7191,7 @@ dependencies = [ "vite_migration", "vite_path", "vite_shared", + "vite_static_config", "vite_str", "vite_task", "vite_workspace", @@ -7411,6 +7412,18 @@ dependencies = [ "vite_str", ] +[[package]] +name = "vite_static_config" +version = "0.0.0" +dependencies = [ + "oxc", + "oxc_allocator", + "rustc-hash", + "serde_json", + "tempfile", + "vite_path", +] + [[package]] name = "vite_str" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1924579527..996e920676 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -187,6 +187,7 @@ vite_glob = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "6f vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } vite_shared = { path = "crates/vite_shared" } +vite_static_config = { path = "crates/vite_static_config" } vite_path = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "6fdc4f106563491be4fb36381b84c5937d74fe9c" } vite_str = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "6fdc4f106563491be4fb36381b84c5937d74fe9c" } vite_task = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "6fdc4f106563491be4fb36381b84c5937d74fe9c" } diff --git a/crates/vite_static_config/Cargo.toml b/crates/vite_static_config/Cargo.toml new file mode 100644 index 0000000000..97870c8f2f --- /dev/null +++ b/crates/vite_static_config/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vite_static_config" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +oxc = { workspace = true } +oxc_allocator = { workspace = true } +rustc-hash = { workspace = true } +serde_json = { workspace = true } +vite_path = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs new file mode 100644 index 0000000000..6c86760b70 --- /dev/null +++ b/crates/vite_static_config/src/lib.rs @@ -0,0 +1,773 @@ +//! Static config extraction from vite.config.* files. +//! +//! Parses vite config files statically (without executing JavaScript) to extract +//! top-level fields whose values are pure JSON literals. This allows reading +//! config like `run` without needing a Node.js runtime. + +use oxc::{ + ast::ast::{ + ArrayExpressionElement, Expression, ObjectPropertyKind, Program, PropertyKey, Statement, + }, + parser::Parser, + span::SourceType, +}; +use oxc_allocator::Allocator; +use rustc_hash::FxHashMap; +use vite_path::AbsolutePath; + +/// Config file names to try, in priority order. +const CONFIG_FILE_NAMES: &[&str] = &[ + "vite.config.ts", + "vite.config.js", + "vite.config.mts", + "vite.config.mjs", + "vite.config.cts", + "vite.config.cjs", +]; + +/// Resolve the vite config file path in the given directory. +/// +/// Tries each config file name in priority order and returns the first one that exists. +fn resolve_config_path(dir: &AbsolutePath) -> Option { + for name in CONFIG_FILE_NAMES { + let path = dir.join(name); + if path.as_path().exists() { + return Some(path); + } + } + None +} + +/// Resolve and parse a vite config file from the given directory. +/// +/// Returns a map of top-level field names to their JSON values for fields +/// whose values are pure JSON literals. Fields with non-JSON values (function calls, +/// variables, template literals, etc.) are skipped. +/// +/// # Arguments +/// * `dir` - The directory to search for a vite config file +/// +/// # Returns +/// A map of field name to JSON value for all statically extractable fields. +/// Returns an empty map if no config file is found or if it cannot be parsed. +#[must_use] +pub fn resolve_static_config(dir: &AbsolutePath) -> FxHashMap, serde_json::Value> { + let Some(config_path) = resolve_config_path(dir) else { + return FxHashMap::default(); + }; + + let Ok(source) = std::fs::read_to_string(&config_path) else { + return FxHashMap::default(); + }; + + let extension = config_path.as_path().extension().and_then(|e| e.to_str()).unwrap_or(""); + + if extension == "json" { + return parse_json_config(&source); + } + + parse_js_ts_config(&source, extension) +} + +/// Parse a JSON config file into a map of field names to values. +fn parse_json_config(source: &str) -> FxHashMap, serde_json::Value> { + let Ok(value) = serde_json::from_str::(source) else { + return FxHashMap::default(); + }; + let Some(obj) = value.as_object() else { + return FxHashMap::default(); + }; + obj.iter().map(|(k, v)| (Box::from(k.as_str()), v.clone())).collect() +} + +/// Parse a JS/TS config file, extracting the default export object's fields. +fn parse_js_ts_config(source: &str, extension: &str) -> FxHashMap, serde_json::Value> { + let allocator = Allocator::default(); + let source_type = match extension { + "ts" | "mts" | "cts" => SourceType::ts(), + _ => SourceType::mjs(), + }; + + let parser = Parser::new(&allocator, source, source_type); + let result = parser.parse(); + + if result.panicked || !result.errors.is_empty() { + return FxHashMap::default(); + } + + extract_default_export_fields(&result.program) +} + +/// Find the default export in a parsed program and extract its object fields. +/// +/// Supports two patterns: +/// 1. `export default defineConfig({ ... })` +/// 2. `export default { ... }` +fn extract_default_export_fields(program: &Program<'_>) -> FxHashMap, serde_json::Value> { + for stmt in &program.body { + let Statement::ExportDefaultDeclaration(decl) = stmt else { + continue; + }; + + let Some(expr) = decl.declaration.as_expression() else { + continue; + }; + + // Unwrap parenthesized expressions + let expr = expr.without_parentheses(); + + match expr { + // Pattern: export default defineConfig({ ... }) + Expression::CallExpression(call) => { + if !is_define_config_call(&call.callee) { + continue; + } + if let Some(first_arg) = call.arguments.first() + && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() + { + return extract_object_fields(obj); + } + } + // Pattern: export default { ... } + Expression::ObjectExpression(obj) => { + return extract_object_fields(obj); + } + _ => {} + } + } + + FxHashMap::default() +} + +/// Check if a callee expression is `defineConfig`. +fn is_define_config_call(callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(ident) if ident.name == "defineConfig") +} + +/// Extract fields from an object expression, converting each value to JSON. +/// Fields whose values cannot be represented as pure JSON are skipped. +fn extract_object_fields( + obj: &oxc::ast::ast::ObjectExpression<'_>, +) -> FxHashMap, serde_json::Value> { + let mut map = FxHashMap::default(); + + for prop in &obj.properties { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + // Skip spread elements + continue; + }; + + // Skip computed properties + if prop.computed { + continue; + } + + let Some(key) = property_key_to_string(&prop.key) else { + continue; + }; + + if let Some(value) = expr_to_json(&prop.value) { + map.insert(key, value); + } + } + + map +} + +/// Convert a property key to a string. +fn property_key_to_string(key: &PropertyKey<'_>) -> Option> { + match key { + PropertyKey::StaticIdentifier(ident) => Some(Box::from(ident.name.as_str())), + PropertyKey::StringLiteral(lit) => Some(Box::from(lit.value.as_str())), + PropertyKey::NumericLiteral(lit) => { + let s = if lit.value.fract() == 0.0 && lit.value.is_finite() { + #[expect(clippy::cast_possible_truncation)] + { + (lit.value as i64).to_string() + } + } else { + lit.value.to_string() + }; + Some(Box::from(s.as_str())) + } + _ => None, + } +} + +/// Convert an f64 to a JSON value, preserving integers when possible. +#[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)] +fn f64_to_json_number(value: f64) -> serde_json::Value { + // If the value is a whole number that fits in i64, use integer representation + if value.fract() == 0.0 + && value.is_finite() + && value >= i64::MIN as f64 + && value <= i64::MAX as f64 + { + serde_json::Value::Number(serde_json::Number::from(value as i64)) + } else if let Some(n) = serde_json::Number::from_f64(value) { + serde_json::Value::Number(n) + } else { + serde_json::Value::Null + } +} + +/// Try to convert an AST expression to a JSON value. +/// +/// Returns `None` if the expression contains non-JSON-literal nodes +/// (function calls, identifiers, template literals, etc.) +fn expr_to_json(expr: &Expression<'_>) -> Option { + let expr = expr.without_parentheses(); + match expr { + Expression::NullLiteral(_) => Some(serde_json::Value::Null), + + Expression::BooleanLiteral(lit) => Some(serde_json::Value::Bool(lit.value)), + + Expression::NumericLiteral(lit) => Some(f64_to_json_number(lit.value)), + + Expression::StringLiteral(lit) => Some(serde_json::Value::String(lit.value.to_string())), + + Expression::TemplateLiteral(lit) => { + // Only convert template literals with no expressions (pure strings) + if lit.expressions.is_empty() && lit.quasis.len() == 1 { + let raw = &lit.quasis[0].value.cooked.as_ref()?; + Some(serde_json::Value::String(raw.to_string())) + } else { + None + } + } + + Expression::UnaryExpression(unary) => { + // Handle negative numbers: -42 + if unary.operator == oxc::ast::ast::UnaryOperator::UnaryNegation + && let Expression::NumericLiteral(lit) = &unary.argument + { + return Some(f64_to_json_number(-lit.value)); + } + None + } + + Expression::ArrayExpression(arr) => { + let mut values = Vec::with_capacity(arr.elements.len()); + for elem in &arr.elements { + match elem { + ArrayExpressionElement::Elision(_) => { + values.push(serde_json::Value::Null); + } + ArrayExpressionElement::SpreadElement(_) => { + return None; + } + _ => { + let elem_expr = elem.as_expression()?; + values.push(expr_to_json(elem_expr)?); + } + } + } + Some(serde_json::Value::Array(values)) + } + + Expression::ObjectExpression(obj) => { + let mut map = serde_json::Map::new(); + for prop in &obj.properties { + match prop { + ObjectPropertyKind::ObjectProperty(prop) => { + if prop.computed { + return None; + } + let key = property_key_to_json_key(&prop.key)?; + let value = expr_to_json(&prop.value)?; + map.insert(key, value); + } + ObjectPropertyKind::SpreadProperty(_) => { + return None; + } + } + } + Some(serde_json::Value::Object(map)) + } + + _ => None, + } +} + +/// Convert a property key to a JSON-compatible string key. +#[expect(clippy::disallowed_types)] +fn property_key_to_json_key(key: &PropertyKey<'_>) -> Option { + match key { + PropertyKey::StaticIdentifier(ident) => Some(ident.name.to_string()), + PropertyKey::StringLiteral(lit) => Some(lit.value.to_string()), + PropertyKey::NumericLiteral(lit) => { + if lit.value.fract() == 0.0 && lit.value.is_finite() { + #[expect(clippy::cast_possible_truncation)] + { + Some((lit.value as i64).to_string()) + } + } else { + Some(lit.value.to_string()) + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + fn parse(source: &str) -> FxHashMap, serde_json::Value> { + parse_js_ts_config(source, "ts") + } + + // ── Config file resolution ────────────────────────────────────────── + + #[test] + fn resolves_ts_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir.path().join("vite.config.ts"), "export default { run: {} }").unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.contains_key("run")); + } + + #[test] + fn resolves_js_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir.path().join("vite.config.js"), "export default { run: {} }").unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.contains_key("run")); + } + + #[test] + fn resolves_mts_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir.path().join("vite.config.mts"), "export default { run: {} }").unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.contains_key("run")); + } + + #[test] + fn ts_takes_priority_over_js() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir.path().join("vite.config.ts"), "export default { fromTs: true }") + .unwrap(); + std::fs::write(dir.path().join("vite.config.js"), "export default { fromJs: true }") + .unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.contains_key("fromTs")); + assert!(!result.contains_key("fromJs")); + } + + #[test] + fn returns_empty_for_no_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.is_empty()); + } + + // ── JSON config parsing ───────────────────────────────────────────── + + #[test] + fn parses_json_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write( + dir.path().join("vite.config.ts"), + r#"export default { run: { tasks: { build: { command: "echo hello" } } } }"#, + ) + .unwrap(); + let result = resolve_static_config(&dir_path); + let run = result.get("run").unwrap(); + assert_eq!(run, &serde_json::json!({ "tasks": { "build": { "command": "echo hello" } } })); + } + + // ── export default { ... } ────────────────────────────────────────── + + #[test] + fn plain_export_default_object() { + let result = parse("export default { foo: 'bar', num: 42 }"); + assert_eq!(result.get("foo").unwrap(), &serde_json::json!("bar")); + assert_eq!(result.get("num").unwrap(), &serde_json::json!(42)); + } + + #[test] + fn export_default_empty_object() { + let result = parse("export default {}"); + assert!(result.is_empty()); + } + + // ── export default defineConfig({ ... }) ──────────────────────────── + + #[test] + fn define_config_call() { + let result = parse( + r#" + import { defineConfig } from 'vite-plus'; + export default defineConfig({ + run: { cacheScripts: true }, + lint: { plugins: ['a'] }, + }); + "#, + ); + assert_eq!(result.get("run").unwrap(), &serde_json::json!({ "cacheScripts": true })); + assert_eq!(result.get("lint").unwrap(), &serde_json::json!({ "plugins": ["a"] })); + } + + // ── Primitive values ──────────────────────────────────────────────── + + #[test] + fn string_values() { + let result = parse(r#"export default { a: "double", b: 'single' }"#); + assert_eq!(result.get("a").unwrap(), &serde_json::json!("double")); + assert_eq!(result.get("b").unwrap(), &serde_json::json!("single")); + } + + #[test] + fn numeric_values() { + let result = parse("export default { a: 42, b: 3.14, c: 0, d: -1 }"); + assert_eq!(result.get("a").unwrap(), &serde_json::json!(42)); + assert_eq!(result.get("b").unwrap(), &serde_json::json!(3.14)); + assert_eq!(result.get("c").unwrap(), &serde_json::json!(0)); + assert_eq!(result.get("d").unwrap(), &serde_json::json!(-1)); + } + + #[test] + fn boolean_values() { + let result = parse("export default { a: true, b: false }"); + assert_eq!(result.get("a").unwrap(), &serde_json::json!(true)); + assert_eq!(result.get("b").unwrap(), &serde_json::json!(false)); + } + + #[test] + fn null_value() { + let result = parse("export default { a: null }"); + assert_eq!(result.get("a").unwrap(), &serde_json::Value::Null); + } + + // ── Arrays ────────────────────────────────────────────────────────── + + #[test] + fn array_of_strings() { + let result = parse("export default { items: ['a', 'b', 'c'] }"); + assert_eq!(result.get("items").unwrap(), &serde_json::json!(["a", "b", "c"])); + } + + #[test] + fn nested_arrays() { + let result = parse("export default { matrix: [[1, 2], [3, 4]] }"); + assert_eq!(result.get("matrix").unwrap(), &serde_json::json!([[1, 2], [3, 4]])); + } + + #[test] + fn empty_array() { + let result = parse("export default { items: [] }"); + assert_eq!(result.get("items").unwrap(), &serde_json::json!([])); + } + + // ── Nested objects ────────────────────────────────────────────────── + + #[test] + fn nested_object() { + let result = parse( + r#"export default { + run: { + tasks: { + build: { + command: "echo build", + dependsOn: ["lint"], + cache: true, + } + } + } + }"#, + ); + assert_eq!( + result.get("run").unwrap(), + &serde_json::json!({ + "tasks": { + "build": { + "command": "echo build", + "dependsOn": ["lint"], + "cache": true, + } + } + }) + ); + } + + // ── Skipping non-JSON fields ──────────────────────────────────────── + + #[test] + fn skips_function_call_values() { + let result = parse( + r#"export default { + run: { cacheScripts: true }, + plugins: [myPlugin()], + }"#, + ); + assert!(result.contains_key("run")); + assert!(!result.contains_key("plugins")); + } + + #[test] + fn skips_identifier_values() { + let result = parse( + r#" + const myVar = 'hello'; + export default { a: myVar, b: 42 } + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + #[test] + fn skips_template_literal_with_expressions() { + let result = parse( + r#" + const x = 'world'; + export default { a: `hello ${x}`, b: 'plain' } + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + #[test] + fn keeps_pure_template_literal() { + let result = parse("export default { a: `hello` }"); + assert_eq!(result.get("a").unwrap(), &serde_json::json!("hello")); + } + + #[test] + fn skips_spread_in_object_value() { + let result = parse( + r#" + const base = { x: 1 }; + export default { a: { ...base, y: 2 }, b: 'ok' } + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + #[test] + fn skips_spread_in_top_level() { + let result = parse( + r#" + const base = { x: 1 }; + export default { ...base, b: 'ok' } + "#, + ); + // Spread at top level is skipped; plain fields are kept + assert!(!result.contains_key("x")); + assert!(result.contains_key("b")); + } + + #[test] + fn skips_computed_properties() { + let result = parse( + r#" + const key = 'dynamic'; + export default { [key]: 'value', plain: 'ok' } + "#, + ); + assert!(!result.contains_key("dynamic")); + assert!(result.contains_key("plain")); + } + + #[test] + fn skips_array_with_spread() { + let result = parse( + r#" + const arr = [1, 2]; + export default { a: [...arr, 3], b: 'ok' } + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + // ── Property key types ────────────────────────────────────────────── + + #[test] + fn string_literal_keys() { + let result = parse(r#"export default { 'string-key': 42 }"#); + assert_eq!(result.get("string-key").unwrap(), &serde_json::json!(42)); + } + + // ── Real-world patterns ───────────────────────────────────────────── + + #[test] + fn real_world_run_config() { + let result = parse( + r#" + export default { + run: { + tasks: { + build: { + command: "echo 'build from vite.config.ts'", + dependsOn: [], + }, + }, + }, + }; + "#, + ); + assert_eq!( + result.get("run").unwrap(), + &serde_json::json!({ + "tasks": { + "build": { + "command": "echo 'build from vite.config.ts'", + "dependsOn": [], + } + } + }) + ); + } + + #[test] + fn real_world_with_non_json_fields() { + let result = parse( + r#" + import { defineConfig } from 'vite-plus'; + + export default defineConfig({ + lint: { + plugins: ['unicorn', 'typescript'], + rules: { + 'no-console': ['error', { allow: ['error'] }], + }, + }, + run: { + tasks: { + 'build:src': { + command: 'vp run rolldown#build-binding:release', + }, + }, + }, + }); + "#, + ); + assert!(result.contains_key("lint")); + assert!(result.contains_key("run")); + assert_eq!( + result.get("run").unwrap(), + &serde_json::json!({ + "tasks": { + "build:src": { + "command": "vp run rolldown#build-binding:release", + } + } + }) + ); + } + + #[test] + fn skips_non_default_exports() { + let result = parse( + r#" + export const config = { a: 1 }; + export default { b: 2 }; + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + #[test] + fn returns_empty_for_no_default_export() { + let result = parse("export const config = { a: 1 };"); + assert!(result.is_empty()); + } + + #[test] + fn returns_empty_for_non_object_default_export() { + let result = parse("export default 42;"); + assert!(result.is_empty()); + } + + #[test] + fn returns_empty_for_unknown_function_call() { + let result = parse("export default someOtherFn({ a: 1 });"); + assert!(result.is_empty()); + } + + #[test] + fn handles_trailing_commas() { + let result = parse( + r#"export default { + a: [1, 2, 3,], + b: { x: 1, y: 2, }, + }"#, + ); + assert_eq!(result.get("a").unwrap(), &serde_json::json!([1, 2, 3])); + assert_eq!(result.get("b").unwrap(), &serde_json::json!({ "x": 1, "y": 2 })); + } + + #[test] + fn task_with_cache_config() { + let result = parse( + r#"export default { + run: { + tasks: { + hello: { + command: 'node hello.mjs', + envs: ['FOO', 'BAR'], + cache: true, + }, + }, + }, + }"#, + ); + assert_eq!( + result.get("run").unwrap(), + &serde_json::json!({ + "tasks": { + "hello": { + "command": "node hello.mjs", + "envs": ["FOO", "BAR"], + "cache": true, + } + } + }) + ); + } + + #[test] + fn skips_method_call_in_nested_value() { + let result = parse( + r#"export default { + run: { + tasks: { + 'build:src': { + command: ['cmd1', 'cmd2'].join(' && '), + }, + }, + }, + lint: { plugins: ['a'] }, + }"#, + ); + // `run` should be skipped because its nested value contains a method call + assert!(!result.contains_key("run")); + // `lint` is pure JSON and should be kept + assert!(result.contains_key("lint")); + } + + #[test] + fn cache_scripts_only() { + let result = parse( + r#"export default { + run: { + cacheScripts: true, + }, + }"#, + ); + assert_eq!(result.get("run").unwrap(), &serde_json::json!({ "cacheScripts": true })); + } +} diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index afcd92417e..76d3428314 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -26,6 +26,7 @@ vite_install = { workspace = true } vite_migration = { workspace = true } vite_path = { workspace = true } vite_shared = { workspace = true } +vite_static_config = { workspace = true } vite_str = { workspace = true } vite_task = { workspace = true } vite_workspace = { workspace = true } diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 8059ccec06..9b03fde21d 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -735,6 +735,18 @@ impl UserConfigLoader for VitePlusConfigLoader { &self, package_path: &AbsolutePath, ) -> anyhow::Result> { + // Try static config extraction first (no JS runtime needed) + let static_config = vite_static_config::resolve_static_config(package_path); + if let Some(run_value) = static_config.get("run") { + tracing::debug!( + "Using statically extracted run config for {}", + package_path.as_path().display() + ); + let run_config: UserRunConfig = serde_json::from_value(run_value.clone())?; + return Ok(Some(run_config)); + } + + // Fall back to NAPI-based config resolution let package_path_str = package_path .as_path() .to_str() From b0a587bb3eec4bbaac9bbca829c492913e129c0a Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 09:50:52 +0800 Subject: [PATCH 02/14] fix(static-config): match Vite's DEFAULT_CONFIG_FILES resolution order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config file resolution order was .ts first, but Vite resolves .js, .mjs, .ts, .cjs, .mts, .cts — matching that order now. Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 6c86760b70..501ce2ef1d 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -16,13 +16,14 @@ use rustc_hash::FxHashMap; use vite_path::AbsolutePath; /// Config file names to try, in priority order. +/// This matches Vite's `DEFAULT_CONFIG_FILES` order. const CONFIG_FILE_NAMES: &[&str] = &[ - "vite.config.ts", "vite.config.js", - "vite.config.mts", "vite.config.mjs", - "vite.config.cts", + "vite.config.ts", "vite.config.cjs", + "vite.config.mts", + "vite.config.cts", ]; /// Resolve the vite config file path in the given directory. @@ -349,7 +350,7 @@ mod tests { } #[test] - fn ts_takes_priority_over_js() { + fn js_takes_priority_over_ts() { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); std::fs::write(dir.path().join("vite.config.ts"), "export default { fromTs: true }") @@ -357,8 +358,8 @@ mod tests { std::fs::write(dir.path().join("vite.config.js"), "export default { fromJs: true }") .unwrap(); let result = resolve_static_config(&dir_path); - assert!(result.contains_key("fromTs")); - assert!(!result.contains_key("fromJs")); + assert!(result.contains_key("fromJs")); + assert!(!result.contains_key("fromTs")); } #[test] From cc776d4795d18b4af5db872deb36e3074c2865d1 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 09:54:24 +0800 Subject: [PATCH 03/14] docs(static-config): add permalink to Vite's DEFAULT_CONFIG_FILES Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 501ce2ef1d..48466c0d40 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -16,7 +16,8 @@ use rustc_hash::FxHashMap; use vite_path::AbsolutePath; /// Config file names to try, in priority order. -/// This matches Vite's `DEFAULT_CONFIG_FILES` order. +/// This matches Vite's `DEFAULT_CONFIG_FILES` order: +/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L119-L126 const CONFIG_FILE_NAMES: &[&str] = &[ "vite.config.js", "vite.config.mjs", From 62d740720a25c7c34cc44f6d57eaf6b4b7d18dc1 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 09:57:26 +0800 Subject: [PATCH 04/14] fix(static-config): correct permalink line ranges, explain no oxc_resolver Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 48466c0d40..4f16f84c4f 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -16,8 +16,12 @@ use rustc_hash::FxHashMap; use vite_path::AbsolutePath; /// Config file names to try, in priority order. -/// This matches Vite's `DEFAULT_CONFIG_FILES` order: -/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L119-L126 +/// This matches Vite's `DEFAULT_CONFIG_FILES`: +/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L98-L105 +/// +/// Vite resolves config files by iterating this list and checking `fs.existsSync` — no +/// module resolution involved, so oxc_resolver is not needed here: +/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/config.ts#L2231-L2237 const CONFIG_FILE_NAMES: &[&str] = &[ "vite.config.js", "vite.config.mjs", From 5111c8da4bee39a01edd3f4dc9eb3060fdf534f6 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:12:26 +0800 Subject: [PATCH 05/14] refactor(static-config): distinguish not-analyzable from field-absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_static_config now returns Option>: - None: config is not analyzable (no file, parse error, no export default, or exported value is not an object literal) — caller should fall back to NAPI - Some(map): config was successfully analyzed - Json(value): field extracted as pure JSON - NonStatic: field exists but value is not a JSON literal - key absent: field does not exist in the config Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 374 +++++++++++++++------------ packages/cli/binding/src/cli.rs | 30 ++- 2 files changed, 235 insertions(+), 169 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 4f16f84c4f..b2064edeb0 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -15,13 +15,35 @@ use oxc_allocator::Allocator; use rustc_hash::FxHashMap; use vite_path::AbsolutePath; +/// The result of statically analyzing a single config field's value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StaticFieldValue { + /// The field value was successfully extracted as a JSON literal. + Json(serde_json::Value), + /// The field exists but its value is not a pure JSON literal (e.g. contains + /// function calls, variables, template literals with expressions, etc.) + NonStatic, +} + +/// The result of statically analyzing a vite config file. +/// +/// - `None` — the config is not analyzable (no config file found, parse error, +/// no `export default`, or the default export is not an object literal). +/// The caller should fall back to a runtime evaluation (e.g. NAPI). +/// - `Some(map)` — the default export object was successfully located. +/// - Key maps to [`StaticFieldValue::Json`] — field value was extracted. +/// - Key maps to [`StaticFieldValue::NonStatic`] — field exists but its value +/// cannot be represented as pure JSON. +/// - Key absent — the field does not exist in the object. +pub type StaticConfig = Option, StaticFieldValue>>; + /// Config file names to try, in priority order. /// This matches Vite's `DEFAULT_CONFIG_FILES`: -/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L98-L105 +/// /// /// Vite resolves config files by iterating this list and checking `fs.existsSync` — no -/// module resolution involved, so oxc_resolver is not needed here: -/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/config.ts#L2231-L2237 +/// module resolution involved, so `oxc_resolver` is not needed here: +/// const CONFIG_FILE_NAMES: &[&str] = &[ "vite.config.js", "vite.config.mjs", @@ -46,25 +68,11 @@ fn resolve_config_path(dir: &AbsolutePath) -> Option /// Resolve and parse a vite config file from the given directory. /// -/// Returns a map of top-level field names to their JSON values for fields -/// whose values are pure JSON literals. Fields with non-JSON values (function calls, -/// variables, template literals, etc.) are skipped. -/// -/// # Arguments -/// * `dir` - The directory to search for a vite config file -/// -/// # Returns -/// A map of field name to JSON value for all statically extractable fields. -/// Returns an empty map if no config file is found or if it cannot be parsed. +/// See [`StaticConfig`] for the return type semantics. #[must_use] -pub fn resolve_static_config(dir: &AbsolutePath) -> FxHashMap, serde_json::Value> { - let Some(config_path) = resolve_config_path(dir) else { - return FxHashMap::default(); - }; - - let Ok(source) = std::fs::read_to_string(&config_path) else { - return FxHashMap::default(); - }; +pub fn resolve_static_config(dir: &AbsolutePath) -> StaticConfig { + let config_path = resolve_config_path(dir)?; + let source = std::fs::read_to_string(&config_path).ok()?; let extension = config_path.as_path().extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -76,18 +84,19 @@ pub fn resolve_static_config(dir: &AbsolutePath) -> FxHashMap, serde_js } /// Parse a JSON config file into a map of field names to values. -fn parse_json_config(source: &str) -> FxHashMap, serde_json::Value> { - let Ok(value) = serde_json::from_str::(source) else { - return FxHashMap::default(); - }; - let Some(obj) = value.as_object() else { - return FxHashMap::default(); - }; - obj.iter().map(|(k, v)| (Box::from(k.as_str()), v.clone())).collect() +/// All fields in a valid JSON object are fully static. +fn parse_json_config(source: &str) -> StaticConfig { + let value: serde_json::Value = serde_json::from_str(source).ok()?; + let obj = value.as_object()?; + Some( + obj.iter() + .map(|(k, v)| (Box::from(k.as_str()), StaticFieldValue::Json(v.clone()))) + .collect(), + ) } /// Parse a JS/TS config file, extracting the default export object's fields. -fn parse_js_ts_config(source: &str, extension: &str) -> FxHashMap, serde_json::Value> { +fn parse_js_ts_config(source: &str, extension: &str) -> StaticConfig { let allocator = Allocator::default(); let source_type = match extension { "ts" | "mts" | "cts" => SourceType::ts(), @@ -98,7 +107,7 @@ fn parse_js_ts_config(source: &str, extension: &str) -> FxHashMap, serd let result = parser.parse(); if result.panicked || !result.errors.is_empty() { - return FxHashMap::default(); + return None; } extract_default_export_fields(&result.program) @@ -106,10 +115,13 @@ fn parse_js_ts_config(source: &str, extension: &str) -> FxHashMap, serd /// Find the default export in a parsed program and extract its object fields. /// +/// Returns `None` if no `export default` is found or the exported value is not +/// an object literal (or `defineConfig({...})` call). +/// /// Supports two patterns: /// 1. `export default defineConfig({ ... })` /// 2. `export default { ... }` -fn extract_default_export_fields(program: &Program<'_>) -> FxHashMap, serde_json::Value> { +fn extract_default_export_fields(program: &Program<'_>) -> StaticConfig { for stmt in &program.body { let Statement::ExportDefaultDeclaration(decl) = stmt else { continue; @@ -126,23 +138,28 @@ fn extract_default_export_fields(program: &Program<'_>) -> FxHashMap, s // Pattern: export default defineConfig({ ... }) Expression::CallExpression(call) => { if !is_define_config_call(&call.callee) { - continue; + // Unknown function call — not analyzable + return None; } if let Some(first_arg) = call.arguments.first() && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() { - return extract_object_fields(obj); + return Some(extract_object_fields(obj)); } + // defineConfig() with non-object arg — not analyzable + return None; } // Pattern: export default { ... } Expression::ObjectExpression(obj) => { - return extract_object_fields(obj); + return Some(extract_object_fields(obj)); } - _ => {} + // e.g. export default 42, export default someVar — not analyzable + _ => return None, } } - FxHashMap::default() + // No export default found + None } /// Check if a callee expression is `defineConfig`. @@ -151,19 +168,21 @@ fn is_define_config_call(callee: &Expression<'_>) -> bool { } /// Extract fields from an object expression, converting each value to JSON. -/// Fields whose values cannot be represented as pure JSON are skipped. +/// Fields whose values cannot be represented as pure JSON are recorded as +/// [`StaticFieldValue::NonStatic`]. Spread elements and computed properties +/// are not representable so they are silently skipped (their keys are unknown). fn extract_object_fields( obj: &oxc::ast::ast::ObjectExpression<'_>, -) -> FxHashMap, serde_json::Value> { +) -> FxHashMap, StaticFieldValue> { let mut map = FxHashMap::default(); for prop in &obj.properties { let ObjectPropertyKind::ObjectProperty(prop) = prop else { - // Skip spread elements + // Spread elements — keys are unknown at static analysis time continue; }; - // Skip computed properties + // Computed properties — keys are unknown at static analysis time if prop.computed { continue; } @@ -172,9 +191,9 @@ fn extract_object_fields( continue; }; - if let Some(value) = expr_to_json(&prop.value) { - map.insert(key, value); - } + let value = expr_to_json(&prop.value) + .map_or(StaticFieldValue::NonStatic, StaticFieldValue::Json); + map.insert(key, value); } map @@ -321,8 +340,28 @@ mod tests { use super::*; - fn parse(source: &str) -> FxHashMap, serde_json::Value> { - parse_js_ts_config(source, "ts") + /// Helper: parse JS/TS source, unwrap the `Some` (asserting it's analyzable), + /// and return the field map. + fn parse(source: &str) -> FxHashMap, StaticFieldValue> { + parse_js_ts_config(source, "ts").expect("expected analyzable config") + } + + /// Shorthand for asserting a field extracted as JSON. + fn assert_json( + map: &FxHashMap, StaticFieldValue>, + key: &str, + expected: serde_json::Value, + ) { + assert_eq!(map.get(key), Some(&StaticFieldValue::Json(expected))); + } + + /// Shorthand for asserting a field is `NonStatic`. + fn assert_non_static(map: &FxHashMap, StaticFieldValue>, key: &str) { + assert_eq!( + map.get(key), + Some(&StaticFieldValue::NonStatic), + "expected field {key:?} to be NonStatic" + ); } // ── Config file resolution ────────────────────────────────────────── @@ -332,7 +371,7 @@ mod tests { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); std::fs::write(dir.path().join("vite.config.ts"), "export default { run: {} }").unwrap(); - let result = resolve_static_config(&dir_path); + let result = resolve_static_config(&dir_path).unwrap(); assert!(result.contains_key("run")); } @@ -341,7 +380,7 @@ mod tests { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); std::fs::write(dir.path().join("vite.config.js"), "export default { run: {} }").unwrap(); - let result = resolve_static_config(&dir_path); + let result = resolve_static_config(&dir_path).unwrap(); assert!(result.contains_key("run")); } @@ -350,7 +389,7 @@ mod tests { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); std::fs::write(dir.path().join("vite.config.mts"), "export default { run: {} }").unwrap(); - let result = resolve_static_config(&dir_path); + let result = resolve_static_config(&dir_path).unwrap(); assert!(result.contains_key("run")); } @@ -362,17 +401,16 @@ mod tests { .unwrap(); std::fs::write(dir.path().join("vite.config.js"), "export default { fromJs: true }") .unwrap(); - let result = resolve_static_config(&dir_path); + let result = resolve_static_config(&dir_path).unwrap(); assert!(result.contains_key("fromJs")); assert!(!result.contains_key("fromTs")); } #[test] - fn returns_empty_for_no_config() { + fn returns_none_for_no_config() { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - let result = resolve_static_config(&dir_path); - assert!(result.is_empty()); + assert!(resolve_static_config(&dir_path).is_none()); } // ── JSON config parsing ───────────────────────────────────────────── @@ -386,9 +424,12 @@ mod tests { r#"export default { run: { tasks: { build: { command: "echo hello" } } } }"#, ) .unwrap(); - let result = resolve_static_config(&dir_path); - let run = result.get("run").unwrap(); - assert_eq!(run, &serde_json::json!({ "tasks": { "build": { "command": "echo hello" } } })); + let result = resolve_static_config(&dir_path).unwrap(); + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "build": { "command": "echo hello" } } }), + ); } // ── export default { ... } ────────────────────────────────────────── @@ -396,8 +437,8 @@ mod tests { #[test] fn plain_export_default_object() { let result = parse("export default { foo: 'bar', num: 42 }"); - assert_eq!(result.get("foo").unwrap(), &serde_json::json!("bar")); - assert_eq!(result.get("num").unwrap(), &serde_json::json!(42)); + assert_json(&result, "foo", serde_json::json!("bar")); + assert_json(&result, "num", serde_json::json!(42)); } #[test] @@ -411,16 +452,16 @@ mod tests { #[test] fn define_config_call() { let result = parse( - r#" + r" import { defineConfig } from 'vite-plus'; export default defineConfig({ run: { cacheScripts: true }, lint: { plugins: ['a'] }, }); - "#, + ", ); - assert_eq!(result.get("run").unwrap(), &serde_json::json!({ "cacheScripts": true })); - assert_eq!(result.get("lint").unwrap(), &serde_json::json!({ "plugins": ["a"] })); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_json(&result, "lint", serde_json::json!({ "plugins": ["a"] })); } // ── Primitive values ──────────────────────────────────────────────── @@ -428,30 +469,30 @@ mod tests { #[test] fn string_values() { let result = parse(r#"export default { a: "double", b: 'single' }"#); - assert_eq!(result.get("a").unwrap(), &serde_json::json!("double")); - assert_eq!(result.get("b").unwrap(), &serde_json::json!("single")); + assert_json(&result, "a", serde_json::json!("double")); + assert_json(&result, "b", serde_json::json!("single")); } #[test] fn numeric_values() { - let result = parse("export default { a: 42, b: 3.14, c: 0, d: -1 }"); - assert_eq!(result.get("a").unwrap(), &serde_json::json!(42)); - assert_eq!(result.get("b").unwrap(), &serde_json::json!(3.14)); - assert_eq!(result.get("c").unwrap(), &serde_json::json!(0)); - assert_eq!(result.get("d").unwrap(), &serde_json::json!(-1)); + let result = parse("export default { a: 42, b: 1.5, c: 0, d: -1 }"); + assert_json(&result, "a", serde_json::json!(42)); + assert_json(&result, "b", serde_json::json!(1.5)); + assert_json(&result, "c", serde_json::json!(0)); + assert_json(&result, "d", serde_json::json!(-1)); } #[test] fn boolean_values() { let result = parse("export default { a: true, b: false }"); - assert_eq!(result.get("a").unwrap(), &serde_json::json!(true)); - assert_eq!(result.get("b").unwrap(), &serde_json::json!(false)); + assert_json(&result, "a", serde_json::json!(true)); + assert_json(&result, "b", serde_json::json!(false)); } #[test] fn null_value() { let result = parse("export default { a: null }"); - assert_eq!(result.get("a").unwrap(), &serde_json::Value::Null); + assert_json(&result, "a", serde_json::Value::Null); } // ── Arrays ────────────────────────────────────────────────────────── @@ -459,19 +500,19 @@ mod tests { #[test] fn array_of_strings() { let result = parse("export default { items: ['a', 'b', 'c'] }"); - assert_eq!(result.get("items").unwrap(), &serde_json::json!(["a", "b", "c"])); + assert_json(&result, "items", serde_json::json!(["a", "b", "c"])); } #[test] fn nested_arrays() { let result = parse("export default { matrix: [[1, 2], [3, 4]] }"); - assert_eq!(result.get("matrix").unwrap(), &serde_json::json!([[1, 2], [3, 4]])); + assert_json(&result, "matrix", serde_json::json!([[1, 2], [3, 4]])); } #[test] fn empty_array() { let result = parse("export default { items: [] }"); - assert_eq!(result.get("items").unwrap(), &serde_json::json!([])); + assert_json(&result, "items", serde_json::json!([])); } // ── Nested objects ────────────────────────────────────────────────── @@ -491,9 +532,10 @@ mod tests { } }"#, ); - assert_eq!( - result.get("run").unwrap(), - &serde_json::json!({ + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "build": { "command": "echo build", @@ -501,109 +543,110 @@ mod tests { "cache": true, } } - }) + }), ); } - // ── Skipping non-JSON fields ──────────────────────────────────────── + // ── NonStatic fields ──────────────────────────────────────────────── #[test] - fn skips_function_call_values() { + fn non_static_function_call_values() { let result = parse( - r#"export default { + r"export default { run: { cacheScripts: true }, plugins: [myPlugin()], - }"#, + }", ); - assert!(result.contains_key("run")); - assert!(!result.contains_key("plugins")); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); } #[test] - fn skips_identifier_values() { + fn non_static_identifier_values() { let result = parse( - r#" + r" const myVar = 'hello'; export default { a: myVar, b: 42 } - "#, + ", ); - assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!(42)); } #[test] - fn skips_template_literal_with_expressions() { + fn non_static_template_literal_with_expressions() { let result = parse( - r#" + r" const x = 'world'; export default { a: `hello ${x}`, b: 'plain' } - "#, + ", ); - assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!("plain")); } #[test] fn keeps_pure_template_literal() { let result = parse("export default { a: `hello` }"); - assert_eq!(result.get("a").unwrap(), &serde_json::json!("hello")); + assert_json(&result, "a", serde_json::json!("hello")); } #[test] - fn skips_spread_in_object_value() { + fn non_static_spread_in_object_value() { let result = parse( - r#" + r" const base = { x: 1 }; export default { a: { ...base, y: 2 }, b: 'ok' } - "#, + ", ); - assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!("ok")); } #[test] - fn skips_spread_in_top_level() { + fn spread_in_top_level_skipped() { let result = parse( - r#" + r" const base = { x: 1 }; export default { ...base, b: 'ok' } - "#, + ", ); - // Spread at top level is skipped; plain fields are kept + // Spread at top level — keys unknown, so not in map at all assert!(!result.contains_key("x")); - assert!(result.contains_key("b")); + assert_json(&result, "b", serde_json::json!("ok")); } #[test] - fn skips_computed_properties() { + fn computed_properties_skipped() { let result = parse( - r#" + r" const key = 'dynamic'; export default { [key]: 'value', plain: 'ok' } - "#, + ", ); + // Computed key — not in map at all (key is unknown) assert!(!result.contains_key("dynamic")); - assert!(result.contains_key("plain")); + assert_json(&result, "plain", serde_json::json!("ok")); } #[test] - fn skips_array_with_spread() { + fn non_static_array_with_spread() { let result = parse( - r#" + r" const arr = [1, 2]; export default { a: [...arr, 3], b: 'ok' } - "#, + ", ); - assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!("ok")); } // ── Property key types ────────────────────────────────────────────── #[test] fn string_literal_keys() { - let result = parse(r#"export default { 'string-key': 42 }"#); - assert_eq!(result.get("string-key").unwrap(), &serde_json::json!(42)); + let result = parse(r"export default { 'string-key': 42 }"); + assert_json(&result, "string-key", serde_json::json!(42)); } // ── Real-world patterns ───────────────────────────────────────────── @@ -624,23 +667,24 @@ mod tests { }; "#, ); - assert_eq!( - result.get("run").unwrap(), - &serde_json::json!({ + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "build": { "command": "echo 'build from vite.config.ts'", "dependsOn": [], } } - }) + }), ); } #[test] fn real_world_with_non_json_fields() { let result = parse( - r#" + r" import { defineConfig } from 'vite-plus'; export default defineConfig({ @@ -658,68 +702,76 @@ mod tests { }, }, }); - "#, + ", ); - assert!(result.contains_key("lint")); - assert!(result.contains_key("run")); - assert_eq!( - result.get("run").unwrap(), - &serde_json::json!({ + assert_json( + &result, + "lint", + serde_json::json!({ + "plugins": ["unicorn", "typescript"], + "rules": { + "no-console": ["error", { "allow": ["error"] }], + }, + }), + ); + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "build:src": { "command": "vp run rolldown#build-binding:release", } } - }) + }), ); } #[test] fn skips_non_default_exports() { let result = parse( - r#" + r" export const config = { a: 1 }; export default { b: 2 }; - "#, + ", ); assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_json(&result, "b", serde_json::json!(2)); } + // ── Not analyzable cases (return None) ────────────────────────────── + #[test] - fn returns_empty_for_no_default_export() { - let result = parse("export const config = { a: 1 };"); - assert!(result.is_empty()); + fn returns_none_for_no_default_export() { + assert!(parse_js_ts_config("export const config = { a: 1 };", "ts").is_none()); } #[test] - fn returns_empty_for_non_object_default_export() { - let result = parse("export default 42;"); - assert!(result.is_empty()); + fn returns_none_for_non_object_default_export() { + assert!(parse_js_ts_config("export default 42;", "ts").is_none()); } #[test] - fn returns_empty_for_unknown_function_call() { - let result = parse("export default someOtherFn({ a: 1 });"); - assert!(result.is_empty()); + fn returns_none_for_unknown_function_call() { + assert!(parse_js_ts_config("export default someOtherFn({ a: 1 });", "ts").is_none()); } #[test] fn handles_trailing_commas() { let result = parse( - r#"export default { + r"export default { a: [1, 2, 3,], b: { x: 1, y: 2, }, - }"#, + }", ); - assert_eq!(result.get("a").unwrap(), &serde_json::json!([1, 2, 3])); - assert_eq!(result.get("b").unwrap(), &serde_json::json!({ "x": 1, "y": 2 })); + assert_json(&result, "a", serde_json::json!([1, 2, 3])); + assert_json(&result, "b", serde_json::json!({ "x": 1, "y": 2 })); } #[test] fn task_with_cache_config() { let result = parse( - r#"export default { + r"export default { run: { tasks: { hello: { @@ -729,11 +781,12 @@ mod tests { }, }, }, - }"#, + }", ); - assert_eq!( - result.get("run").unwrap(), - &serde_json::json!({ + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "hello": { "command": "node hello.mjs", @@ -741,14 +794,14 @@ mod tests { "cache": true, } } - }) + }), ); } #[test] - fn skips_method_call_in_nested_value() { + fn non_static_method_call_in_nested_value() { let result = parse( - r#"export default { + r"export default { run: { tasks: { 'build:src': { @@ -757,23 +810,22 @@ mod tests { }, }, lint: { plugins: ['a'] }, - }"#, + }", ); - // `run` should be skipped because its nested value contains a method call - assert!(!result.contains_key("run")); - // `lint` is pure JSON and should be kept - assert!(result.contains_key("lint")); + // `run` is NonStatic because its nested value contains a method call + assert_non_static(&result, "run"); + assert_json(&result, "lint", serde_json::json!({ "plugins": ["a"] })); } #[test] fn cache_scripts_only() { let result = parse( - r#"export default { + r"export default { run: { cacheScripts: true, }, - }"#, + }", ); - assert_eq!(result.get("run").unwrap(), &serde_json::json!({ "cacheScripts": true })); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } } diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 9b03fde21d..f7a025a422 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -736,14 +736,28 @@ impl UserConfigLoader for VitePlusConfigLoader { package_path: &AbsolutePath, ) -> anyhow::Result> { // Try static config extraction first (no JS runtime needed) - let static_config = vite_static_config::resolve_static_config(package_path); - if let Some(run_value) = static_config.get("run") { - tracing::debug!( - "Using statically extracted run config for {}", - package_path.as_path().display() - ); - let run_config: UserRunConfig = serde_json::from_value(run_value.clone())?; - return Ok(Some(run_config)); + if let Some(static_fields) = vite_static_config::resolve_static_config(package_path) { + match static_fields.get("run") { + Some(vite_static_config::StaticFieldValue::Json(run_value)) => { + tracing::debug!( + "Using statically extracted run config for {}", + package_path.as_path().display() + ); + let run_config: UserRunConfig = serde_json::from_value(run_value.clone())?; + return Ok(Some(run_config)); + } + Some(vite_static_config::StaticFieldValue::NonStatic) => { + // `run` field exists but contains non-static values — fall back to NAPI + tracing::debug!( + "run config is not statically analyzable for {}, falling back to NAPI", + package_path.as_path().display() + ); + } + None => { + // Config was analyzed successfully but has no `run` field + return Ok(None); + } + } } // Fall back to NAPI-based config resolution From 995759a8d58b7e135f3ef9ce4226a4813a0f4cdf Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:24:16 +0800 Subject: [PATCH 06/14] refactor(static-config): use oxc utility methods instead of manual matching - PropertyKey::static_name() replaces property_key_to_string and property_key_to_json_key - TemplateLiteral::single_quasi() replaces manual quasis/expressions check - Expression::is_specific_id() replaces is_define_config_call helper - ArrayExpressionElement::is_elision()/is_spread() replaces variant matching - ObjectPropertyKind::is_spread() replaces variant matching Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 117 +++++++-------------------- 1 file changed, 28 insertions(+), 89 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index b2064edeb0..8174d07f6e 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -5,9 +5,7 @@ //! config like `run` without needing a Node.js runtime. use oxc::{ - ast::ast::{ - ArrayExpressionElement, Expression, ObjectPropertyKind, Program, PropertyKey, Statement, - }, + ast::ast::{Expression, ObjectPropertyKind, Program, Statement}, parser::Parser, span::SourceType, }; @@ -137,7 +135,7 @@ fn extract_default_export_fields(program: &Program<'_>) -> StaticConfig { match expr { // Pattern: export default defineConfig({ ... }) Expression::CallExpression(call) => { - if !is_define_config_call(&call.callee) { + if !call.callee.is_specific_id("defineConfig") { // Unknown function call — not analyzable return None; } @@ -162,11 +160,6 @@ fn extract_default_export_fields(program: &Program<'_>) -> StaticConfig { None } -/// Check if a callee expression is `defineConfig`. -fn is_define_config_call(callee: &Expression<'_>) -> bool { - matches!(callee, Expression::Identifier(ident) if ident.name == "defineConfig") -} - /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as /// [`StaticFieldValue::NonStatic`]. Spread elements and computed properties @@ -177,48 +170,27 @@ fn extract_object_fields( let mut map = FxHashMap::default(); for prop in &obj.properties { - let ObjectPropertyKind::ObjectProperty(prop) = prop else { + if prop.is_spread() { // Spread elements — keys are unknown at static analysis time continue; - }; - - // Computed properties — keys are unknown at static analysis time - if prop.computed { - continue; } + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + continue; + }; - let Some(key) = property_key_to_string(&prop.key) else { + let Some(key) = prop.key.static_name() else { + // Computed properties — keys are unknown at static analysis time continue; }; - let value = expr_to_json(&prop.value) - .map_or(StaticFieldValue::NonStatic, StaticFieldValue::Json); - map.insert(key, value); + let value = + expr_to_json(&prop.value).map_or(StaticFieldValue::NonStatic, StaticFieldValue::Json); + map.insert(Box::from(key.as_ref()), value); } map } -/// Convert a property key to a string. -fn property_key_to_string(key: &PropertyKey<'_>) -> Option> { - match key { - PropertyKey::StaticIdentifier(ident) => Some(Box::from(ident.name.as_str())), - PropertyKey::StringLiteral(lit) => Some(Box::from(lit.value.as_str())), - PropertyKey::NumericLiteral(lit) => { - let s = if lit.value.fract() == 0.0 && lit.value.is_finite() { - #[expect(clippy::cast_possible_truncation)] - { - (lit.value as i64).to_string() - } - } else { - lit.value.to_string() - }; - Some(Box::from(s.as_str())) - } - _ => None, - } -} - /// Convert an f64 to a JSON value, preserving integers when possible. #[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)] fn f64_to_json_number(value: f64) -> serde_json::Value { @@ -252,13 +224,8 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { Expression::StringLiteral(lit) => Some(serde_json::Value::String(lit.value.to_string())), Expression::TemplateLiteral(lit) => { - // Only convert template literals with no expressions (pure strings) - if lit.expressions.is_empty() && lit.quasis.len() == 1 { - let raw = &lit.quasis[0].value.cooked.as_ref()?; - Some(serde_json::Value::String(raw.to_string())) - } else { - None - } + let quasi = lit.single_quasi()?; + Some(serde_json::Value::String(quasi.to_string())) } Expression::UnaryExpression(unary) => { @@ -274,17 +241,13 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { Expression::ArrayExpression(arr) => { let mut values = Vec::with_capacity(arr.elements.len()); for elem in &arr.elements { - match elem { - ArrayExpressionElement::Elision(_) => { - values.push(serde_json::Value::Null); - } - ArrayExpressionElement::SpreadElement(_) => { - return None; - } - _ => { - let elem_expr = elem.as_expression()?; - values.push(expr_to_json(elem_expr)?); - } + if elem.is_elision() { + values.push(serde_json::Value::Null); + } else if elem.is_spread() { + return None; + } else { + let elem_expr = elem.as_expression()?; + values.push(expr_to_json(elem_expr)?); } } Some(serde_json::Value::Array(values)) @@ -293,19 +256,15 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { Expression::ObjectExpression(obj) => { let mut map = serde_json::Map::new(); for prop in &obj.properties { - match prop { - ObjectPropertyKind::ObjectProperty(prop) => { - if prop.computed { - return None; - } - let key = property_key_to_json_key(&prop.key)?; - let value = expr_to_json(&prop.value)?; - map.insert(key, value); - } - ObjectPropertyKind::SpreadProperty(_) => { - return None; - } + if prop.is_spread() { + return None; } + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + continue; + }; + let key = prop.key.static_name()?; + let value = expr_to_json(&prop.value)?; + map.insert(key.into_owned(), value); } Some(serde_json::Value::Object(map)) } @@ -314,26 +273,6 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { } } -/// Convert a property key to a JSON-compatible string key. -#[expect(clippy::disallowed_types)] -fn property_key_to_json_key(key: &PropertyKey<'_>) -> Option { - match key { - PropertyKey::StaticIdentifier(ident) => Some(ident.name.to_string()), - PropertyKey::StringLiteral(lit) => Some(lit.value.to_string()), - PropertyKey::NumericLiteral(lit) => { - if lit.value.fract() == 0.0 && lit.value.is_finite() { - #[expect(clippy::cast_possible_truncation)] - { - Some((lit.value as i64).to_string()) - } - } else { - Some(lit.value.to_string()) - } - } - _ => None, - } -} - #[cfg(test)] mod tests { use tempfile::TempDir; From e1c670cc886a1b37aa7955edec3e8f040d33859a Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:28:42 +0800 Subject: [PATCH 07/14] feat(static-config): support module.exports CJS pattern Add support for CommonJS config files: - module.exports = { ... } - module.exports = defineConfig({ ... }) Refactored shared config extraction into extract_config_from_expr, used by both export default and module.exports paths. Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 110 ++++++++++++++++++--------- 1 file changed, 75 insertions(+), 35 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 8174d07f6e..cf1882e879 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -108,56 +108,62 @@ fn parse_js_ts_config(source: &str, extension: &str) -> StaticConfig { return None; } - extract_default_export_fields(&result.program) + extract_config_fields(&result.program) } -/// Find the default export in a parsed program and extract its object fields. +/// Find the config object in a parsed program and extract its fields. /// -/// Returns `None` if no `export default` is found or the exported value is not -/// an object literal (or `defineConfig({...})` call). -/// -/// Supports two patterns: +/// Searches for the config value in the following patterns (in order): /// 1. `export default defineConfig({ ... })` /// 2. `export default { ... }` -fn extract_default_export_fields(program: &Program<'_>) -> StaticConfig { +/// 3. `module.exports = defineConfig({ ... })` +/// 4. `module.exports = { ... }` +fn extract_config_fields(program: &Program<'_>) -> StaticConfig { for stmt in &program.body { - let Statement::ExportDefaultDeclaration(decl) = stmt else { - continue; - }; + // ESM: export default ... + if let Statement::ExportDefaultDeclaration(decl) = stmt { + if let Some(expr) = decl.declaration.as_expression() { + return extract_config_from_expr(expr); + } + // export default class/function — not analyzable + return None; + } - let Some(expr) = decl.declaration.as_expression() else { - continue; - }; + // CJS: module.exports = ... + if let Statement::ExpressionStatement(expr_stmt) = stmt + && let Expression::AssignmentExpression(assign) = &expr_stmt.expression + && assign.left.as_member_expression().is_some_and(|m| { + m.object().is_specific_id("module") && m.static_property_name() == Some("exports") + }) + { + return extract_config_from_expr(&assign.right); + } + } - // Unwrap parenthesized expressions - let expr = expr.without_parentheses(); + None +} - match expr { - // Pattern: export default defineConfig({ ... }) - Expression::CallExpression(call) => { - if !call.callee.is_specific_id("defineConfig") { - // Unknown function call — not analyzable - return None; - } - if let Some(first_arg) = call.arguments.first() - && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() - { - return Some(extract_object_fields(obj)); - } - // defineConfig() with non-object arg — not analyzable +/// Extract the config object from an expression that is either: +/// - `defineConfig({ ... })` → extract the object argument +/// - `{ ... }` → extract directly +/// - anything else → not analyzable +fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { + let expr = expr.without_parentheses(); + match expr { + Expression::CallExpression(call) => { + if !call.callee.is_specific_id("defineConfig") { return None; } - // Pattern: export default { ... } - Expression::ObjectExpression(obj) => { + if let Some(first_arg) = call.arguments.first() + && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() + { return Some(extract_object_fields(obj)); } - // e.g. export default 42, export default someVar — not analyzable - _ => return None, + None } + Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), + _ => None, } - - // No export default found - None } /// Extract fields from an object expression, converting each value to JSON. @@ -403,6 +409,40 @@ mod tests { assert_json(&result, "lint", serde_json::json!({ "plugins": ["a"] })); } + // ── module.exports = { ... } ─────────────────────────────────────── + + #[test] + fn module_exports_object() { + let result = parse_js_ts_config("module.exports = { run: { cache: true } }", "cjs") + .expect("expected analyzable config"); + assert_json(&result, "run", serde_json::json!({ "cache": true })); + } + + #[test] + fn module_exports_define_config() { + let result = parse_js_ts_config( + r" + const { defineConfig } = require('vite-plus'); + module.exports = defineConfig({ + run: { cacheScripts: true }, + }); + ", + "cjs", + ) + .expect("expected analyzable config"); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn module_exports_non_object() { + assert!(parse_js_ts_config("module.exports = 42;", "cjs").is_none()); + } + + #[test] + fn module_exports_unknown_call() { + assert!(parse_js_ts_config("module.exports = otherFn({ a: 1 });", "cjs").is_none()); + } + // ── Primitive values ──────────────────────────────────────────────── #[test] From 00ce4f9f320fc9752d0efdc10344984ae0db164b Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:36:57 +0800 Subject: [PATCH 08/14] refactor(static-config): use specific oxc_* crates and add README Replace umbrella `oxc` dependency with `oxc_ast`, `oxc_parser`, and `oxc_span` for more precise dependency tracking. Add README documenting supported patterns, config resolution order, return type semantics, and limitations. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 4 +- Cargo.toml | 3 ++ crates/vite_static_config/Cargo.toml | 4 +- crates/vite_static_config/README.md | 56 ++++++++++++++++++++++++++++ crates/vite_static_config/src/lib.rs | 12 +++--- 5 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 crates/vite_static_config/README.md diff --git a/Cargo.lock b/Cargo.lock index 359258fb67..77f32daf83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7416,8 +7416,10 @@ dependencies = [ name = "vite_static_config" version = "0.0.0" dependencies = [ - "oxc", "oxc_allocator", + "oxc_ast", + "oxc_parser", + "oxc_span", "rustc-hash", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 996e920676..a9c5a5e539 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -212,7 +212,10 @@ oxc = { version = "0.115.0", features = [ "cfg", ] } oxc_allocator = { version = "0.115.0", features = ["pool"] } +oxc_ast = "0.115.0" oxc_ecmascript = "0.115.0" +oxc_parser = "0.115.0" +oxc_span = "0.115.0" oxc_napi = "0.115.0" oxc_minify_napi = "0.115.0" oxc_parser_napi = "0.115.0" diff --git a/crates/vite_static_config/Cargo.toml b/crates/vite_static_config/Cargo.toml index 97870c8f2f..ae9569923d 100644 --- a/crates/vite_static_config/Cargo.toml +++ b/crates/vite_static_config/Cargo.toml @@ -8,8 +8,10 @@ license.workspace = true repository.workspace = true [dependencies] -oxc = { workspace = true } oxc_allocator = { workspace = true } +oxc_ast = { workspace = true } +oxc_parser = { workspace = true } +oxc_span = { workspace = true } rustc-hash = { workspace = true } serde_json = { workspace = true } vite_path = { workspace = true } diff --git a/crates/vite_static_config/README.md b/crates/vite_static_config/README.md new file mode 100644 index 0000000000..1bc6b94386 --- /dev/null +++ b/crates/vite_static_config/README.md @@ -0,0 +1,56 @@ +# vite_static_config + +Statically extracts configuration from `vite.config.*` files without executing JavaScript. + +## What it does + +Parses vite config files using [oxc_parser](https://crates.io/crates/oxc_parser) and extracts +top-level fields whose values are pure JSON literals. This allows reading config like `run` +without needing a Node.js runtime (NAPI). + +## Supported patterns + +**ESM:** +```js +export default { run: { tasks: { build: { command: "echo build" } } } } +export default defineConfig({ run: { cacheScripts: true } }) +``` + +**CJS:** +```js +module.exports = { run: { tasks: { build: { command: "echo build" } } } } +module.exports = defineConfig({ run: { cacheScripts: true } }) +``` + +## Config file resolution + +Searches for config files in the same order as Vite's +[`DEFAULT_CONFIG_FILES`](https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L98-L105): + +1. `vite.config.js` +2. `vite.config.mjs` +3. `vite.config.ts` +4. `vite.config.cjs` +5. `vite.config.mts` +6. `vite.config.cts` + +## Return type + +`resolve_static_config` returns `Option, StaticFieldValue>>`: + +- **`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: + - `StaticFieldValue::Json(value)` — field value extracted as pure JSON + - `StaticFieldValue::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 + +## Limitations + +- Only extracts values that are pure JSON literals (strings, numbers, booleans, null, + arrays, and objects composed of these) +- Fields with dynamic values (function calls, variable references, spread operators, + computed properties, template literals with expressions) are reported as `NonStatic` +- Does not follow imports or evaluate expressions diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index cf1882e879..f981a26f93 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -4,12 +4,10 @@ //! top-level fields whose values are pure JSON literals. This allows reading //! config like `run` without needing a Node.js runtime. -use oxc::{ - ast::ast::{Expression, ObjectPropertyKind, Program, Statement}, - parser::Parser, - span::SourceType, -}; use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, ObjectPropertyKind, Program, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; use rustc_hash::FxHashMap; use vite_path::AbsolutePath; @@ -171,7 +169,7 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { /// [`StaticFieldValue::NonStatic`]. Spread elements and computed properties /// are not representable so they are silently skipped (their keys are unknown). fn extract_object_fields( - obj: &oxc::ast::ast::ObjectExpression<'_>, + obj: &oxc_ast::ast::ObjectExpression<'_>, ) -> FxHashMap, StaticFieldValue> { let mut map = FxHashMap::default(); @@ -236,7 +234,7 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { Expression::UnaryExpression(unary) => { // Handle negative numbers: -42 - if unary.operator == oxc::ast::ast::UnaryOperator::UnaryNegation + if unary.operator == oxc_ast::ast::UnaryOperator::UnaryNegation && let Expression::NumericLiteral(lit) = &unary.argument { return Some(f64_to_json_number(-lit.value)); From deab9313f7f5a2e30037d805b3b5ecc4cdd72f8c Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:57:07 +0800 Subject: [PATCH 09/14] refactor(static-config): simplify f64_to_json_number and rename FieldValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite f64_to_json_number to follow JSON.stringify semantics using serde_json's From for the NaN/Infinity→null fallback, and i64::try_from for safe integer conversion. Rename StaticFieldValue to FieldValue for brevity. Add tests for overflow-to-infinity and -0. Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/README.md | 12 +++-- crates/vite_static_config/src/lib.rs | 68 +++++++++++++++------------- packages/cli/binding/src/cli.rs | 4 +- 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/crates/vite_static_config/README.md b/crates/vite_static_config/README.md index 1bc6b94386..e33cc8f12f 100644 --- a/crates/vite_static_config/README.md +++ b/crates/vite_static_config/README.md @@ -11,15 +11,17 @@ without needing a Node.js runtime (NAPI). ## Supported patterns **ESM:** + ```js export default { run: { tasks: { build: { command: "echo build" } } } } export default defineConfig({ run: { cacheScripts: true } }) ``` **CJS:** + ```js -module.exports = { run: { tasks: { build: { command: "echo build" } } } } -module.exports = defineConfig({ run: { cacheScripts: true } }) +module.exports = { run: { tasks: { build: { command: 'echo build' } } } }; +module.exports = defineConfig({ run: { cacheScripts: true } }); ``` ## Config file resolution @@ -36,14 +38,14 @@ Searches for config files in the same order as Vite's ## Return type -`resolve_static_config` returns `Option, StaticFieldValue>>`: +`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: - - `StaticFieldValue::Json(value)` — field value extracted as pure JSON - - `StaticFieldValue::NonStatic` — field exists but contains non-JSON expressions + - `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 diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index f981a26f93..952e53578a 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -13,7 +13,7 @@ use vite_path::AbsolutePath; /// The result of statically analyzing a single config field's value. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum StaticFieldValue { +pub enum FieldValue { /// The field value was successfully extracted as a JSON literal. Json(serde_json::Value), /// The field exists but its value is not a pure JSON literal (e.g. contains @@ -27,11 +27,11 @@ pub enum StaticFieldValue { /// no `export default`, or the default export is not an object literal). /// The caller should fall back to a runtime evaluation (e.g. NAPI). /// - `Some(map)` — the default export object was successfully located. -/// - Key maps to [`StaticFieldValue::Json`] — field value was extracted. -/// - Key maps to [`StaticFieldValue::NonStatic`] — field exists but its value +/// - Key maps to [`FieldValue::Json`] — field value was extracted. +/// - Key maps to [`FieldValue::NonStatic`] — field exists but its value /// cannot be represented as pure JSON. /// - Key absent — the field does not exist in the object. -pub type StaticConfig = Option, StaticFieldValue>>; +pub type StaticConfig = Option, FieldValue>>; /// Config file names to try, in priority order. /// This matches Vite's `DEFAULT_CONFIG_FILES`: @@ -84,11 +84,7 @@ pub fn resolve_static_config(dir: &AbsolutePath) -> StaticConfig { fn parse_json_config(source: &str) -> StaticConfig { let value: serde_json::Value = serde_json::from_str(source).ok()?; let obj = value.as_object()?; - Some( - obj.iter() - .map(|(k, v)| (Box::from(k.as_str()), StaticFieldValue::Json(v.clone()))) - .collect(), - ) + Some(obj.iter().map(|(k, v)| (Box::from(k.as_str()), FieldValue::Json(v.clone()))).collect()) } /// Parse a JS/TS config file, extracting the default export object's fields. @@ -166,11 +162,11 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as -/// [`StaticFieldValue::NonStatic`]. Spread elements and computed properties +/// [`FieldValue::NonStatic`]. Spread elements and computed properties /// are not representable so they are silently skipped (their keys are unknown). fn extract_object_fields( obj: &oxc_ast::ast::ObjectExpression<'_>, -) -> FxHashMap, StaticFieldValue> { +) -> FxHashMap, FieldValue> { let mut map = FxHashMap::default(); for prop in &obj.properties { @@ -187,28 +183,25 @@ fn extract_object_fields( continue; }; - let value = - expr_to_json(&prop.value).map_or(StaticFieldValue::NonStatic, StaticFieldValue::Json); + let value = expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json); map.insert(Box::from(key.as_ref()), value); } map } -/// Convert an f64 to a JSON value, preserving integers when possible. -#[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)] +/// Convert an f64 to a JSON value following `JSON.stringify` semantics. +/// `NaN`, `Infinity`, `-Infinity` become `null`; `-0` becomes `0`. fn f64_to_json_number(value: f64) -> serde_json::Value { - // If the value is a whole number that fits in i64, use integer representation + // fract() == 0.0 ensures the value is a whole number, so the cast is lossless. + #[expect(clippy::cast_possible_truncation)] if value.fract() == 0.0 - && value.is_finite() - && value >= i64::MIN as f64 - && value <= i64::MAX as f64 + && let Ok(i) = i64::try_from(value as i128) { - serde_json::Value::Number(serde_json::Number::from(value as i64)) - } else if let Some(n) = serde_json::Number::from_f64(value) { - serde_json::Value::Number(n) + serde_json::Value::from(i) } else { - serde_json::Value::Null + // From for Value: finite → Number, NaN/Infinity → Null + serde_json::Value::from(value) } } @@ -285,24 +278,20 @@ mod tests { /// Helper: parse JS/TS source, unwrap the `Some` (asserting it's analyzable), /// and return the field map. - fn parse(source: &str) -> FxHashMap, StaticFieldValue> { + fn parse(source: &str) -> FxHashMap, FieldValue> { parse_js_ts_config(source, "ts").expect("expected analyzable config") } /// Shorthand for asserting a field extracted as JSON. - fn assert_json( - map: &FxHashMap, StaticFieldValue>, - key: &str, - expected: serde_json::Value, - ) { - assert_eq!(map.get(key), Some(&StaticFieldValue::Json(expected))); + fn assert_json(map: &FxHashMap, FieldValue>, key: &str, expected: serde_json::Value) { + assert_eq!(map.get(key), Some(&FieldValue::Json(expected))); } /// Shorthand for asserting a field is `NonStatic`. - fn assert_non_static(map: &FxHashMap, StaticFieldValue>, key: &str) { + fn assert_non_static(map: &FxHashMap, FieldValue>, key: &str) { assert_eq!( map.get(key), - Some(&StaticFieldValue::NonStatic), + Some(&FieldValue::NonStatic), "expected field {key:?} to be NonStatic" ); } @@ -459,6 +448,21 @@ mod tests { assert_json(&result, "d", serde_json::json!(-1)); } + #[test] + fn numeric_overflow_to_infinity_is_null() { + // 1e999 overflows f64 to Infinity; JSON.stringify(Infinity) === "null" + let result = parse("export default { a: 1e999, b: -1e999 }"); + assert_json(&result, "a", serde_json::Value::Null); + assert_json(&result, "b", serde_json::Value::Null); + } + + #[test] + fn negative_zero_is_zero() { + // JSON.stringify(-0) === "0" + let result = parse("export default { a: -0 }"); + assert_json(&result, "a", serde_json::json!(0)); + } + #[test] fn boolean_values() { let result = parse("export default { a: true, b: false }"); diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index f7a025a422..8fe80dcfee 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -738,7 +738,7 @@ impl UserConfigLoader for VitePlusConfigLoader { // Try static config extraction first (no JS runtime needed) if let Some(static_fields) = vite_static_config::resolve_static_config(package_path) { match static_fields.get("run") { - Some(vite_static_config::StaticFieldValue::Json(run_value)) => { + Some(vite_static_config::FieldValue::Json(run_value)) => { tracing::debug!( "Using statically extracted run config for {}", package_path.as_path().display() @@ -746,7 +746,7 @@ impl UserConfigLoader for VitePlusConfigLoader { let run_config: UserRunConfig = serde_json::from_value(run_value.clone())?; return Ok(Some(run_config)); } - Some(vite_static_config::StaticFieldValue::NonStatic) => { + Some(vite_static_config::FieldValue::NonStatic) => { // `run` field exists but contains non-static values — fall back to NAPI tracing::debug!( "run config is not statically analyzable for {}, falling back to NAPI", From a9bdd67c487868767b0c5f603d73946db97e6f5f Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 12:42:48 +0800 Subject: [PATCH 10/14] feat(static-config): support defineConfig(fn) and skip NAPI when no config file Two improvements to static config extraction: 1. When no vite.config.* file exists in a workspace package, resolve_static_config now returns an empty map (instead of None). The caller sees no `run` field and returns immediately, skipping the NAPI/JS callback. This eliminates ~165ms cold Node.js init + ~3ms/pkg warm overhead for monorepo packages without config files. 2. Support defineConfig(fn) where fn is an arrow function or function expression. The extractor locates the return expression inside the function body and extracts fields from it. Functions with multiple return statements are rejected as not statically analyzable. Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 212 +++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 11 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 952e53578a..466d4869b9 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -23,14 +23,15 @@ pub enum FieldValue { /// The result of statically analyzing a vite config file. /// -/// - `None` — the config is not analyzable (no config file found, parse error, +/// - `None` — the config file exists but is not analyzable (parse error, /// no `export default`, or the default export is not an object literal). /// The caller should fall back to a runtime evaluation (e.g. NAPI). -/// - `Some(map)` — the default export object was successfully located. +/// - `Some(map)` — the config was successfully resolved. +/// - Empty map — no config file was found (caller can skip runtime evaluation). /// - Key maps to [`FieldValue::Json`] — field value was extracted. /// - Key maps to [`FieldValue::NonStatic`] — field exists but its value /// cannot be represented as pure JSON. -/// - Key absent — the field does not exist in the object. +/// - Key absent — the field does not exist in the config. pub type StaticConfig = Option, FieldValue>>; /// Config file names to try, in priority order. @@ -67,7 +68,11 @@ fn resolve_config_path(dir: &AbsolutePath) -> Option /// See [`StaticConfig`] for the return type semantics. #[must_use] pub fn resolve_static_config(dir: &AbsolutePath) -> StaticConfig { - let config_path = resolve_config_path(dir)?; + let Some(config_path) = resolve_config_path(dir) else { + // No config file found — return empty map so the caller can + // skip runtime evaluation (NAPI) entirely. + return Some(FxHashMap::default()); + }; let source = std::fs::read_to_string(&config_path).ok()?; let extension = config_path.as_path().extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -139,6 +144,9 @@ fn extract_config_fields(program: &Program<'_>) -> StaticConfig { /// Extract the config object from an expression that is either: /// - `defineConfig({ ... })` → extract the object argument +/// - `defineConfig(() => ({ ... }))` → extract from arrow function expression body +/// - `defineConfig(() => { return { ... }; })` → extract from return statement +/// - `defineConfig(function() { return { ... }; })` → extract from return statement /// - `{ ... }` → extract directly /// - anything else → not analyzable fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { @@ -148,18 +156,110 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { if !call.callee.is_specific_id("defineConfig") { return None; } - if let Some(first_arg) = call.arguments.first() - && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() - { - return Some(extract_object_fields(obj)); + let first_arg = call.arguments.first()?; + let first_arg_expr = first_arg.as_expression()?; + match first_arg_expr { + Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), + Expression::ArrowFunctionExpression(arrow) => { + extract_config_from_function_body(&arrow.body) + } + Expression::FunctionExpression(func) => { + extract_config_from_function_body(func.body.as_ref()?) + } + _ => None, } - None } Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), _ => None, } } +/// Extract the config object from the body of a function passed to `defineConfig`. +/// +/// Handles two patterns: +/// - Concise arrow body: `() => ({ ... })` — body has a single `ExpressionStatement` +/// - Block body with exactly one return: `() => { ... return { ... }; }` +/// +/// Returns `None` (not analyzable) if the body contains multiple `return` statements +/// (at any nesting depth), since the returned config would depend on runtime control flow. +fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> StaticConfig { + // Reject functions with multiple returns — the config depends on control flow. + if count_returns_in_stmts(&body.statements) > 1 { + return None; + } + + for stmt in &body.statements { + match stmt { + Statement::ReturnStatement(ret) => { + let arg = ret.argument.as_ref()?; + if let Expression::ObjectExpression(obj) = arg.without_parentheses() { + return Some(extract_object_fields(obj)); + } + return None; + } + Statement::ExpressionStatement(expr_stmt) => { + // Concise arrow: `() => ({ ... })` is represented as ExpressionStatement + if let Expression::ObjectExpression(obj) = + expr_stmt.expression.without_parentheses() + { + return Some(extract_object_fields(obj)); + } + } + _ => {} + } + } + None +} + +/// 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 { + let mut count = 0; + for stmt in stmts { + count += count_returns_in_stmt(stmt); + } + count +} + +fn count_returns_in_stmt(stmt: &Statement<'_>) -> usize { + match stmt { + Statement::ReturnStatement(_) => 1, + Statement::BlockStatement(block) => count_returns_in_stmts(&block.body), + Statement::IfStatement(if_stmt) => { + let mut n = count_returns_in_stmt(&if_stmt.consequent); + if let Some(alt) = &if_stmt.alternate { + n += count_returns_in_stmt(alt); + } + n + } + Statement::SwitchStatement(switch) => { + let mut n = 0; + for case in &switch.cases { + n += count_returns_in_stmts(&case.consequent); + } + n + } + Statement::TryStatement(try_stmt) => { + let mut n = count_returns_in_stmts(&try_stmt.block.body); + if let Some(handler) = &try_stmt.handler { + n += count_returns_in_stmts(&handler.body.body); + } + if let Some(finalizer) = &try_stmt.finalizer { + n += count_returns_in_stmts(&finalizer.body); + } + n + } + Statement::ForStatement(s) => count_returns_in_stmt(&s.body), + Statement::ForInStatement(s) => count_returns_in_stmt(&s.body), + Statement::ForOfStatement(s) => count_returns_in_stmt(&s.body), + Statement::WhileStatement(s) => count_returns_in_stmt(&s.body), + Statement::DoWhileStatement(s) => count_returns_in_stmt(&s.body), + Statement::LabeledStatement(s) => count_returns_in_stmt(&s.body), + Statement::WithStatement(s) => count_returns_in_stmt(&s.body), + _ => 0, + } +} + /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as /// [`FieldValue::NonStatic`]. Spread elements and computed properties @@ -339,10 +439,11 @@ mod tests { } #[test] - fn returns_none_for_no_config() { + fn returns_empty_map_for_no_config() { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - assert!(resolve_static_config(&dir_path).is_none()); + let result = resolve_static_config(&dir_path).unwrap(); + assert!(result.is_empty()); } // ── JSON config parsing ───────────────────────────────────────────── @@ -720,6 +821,95 @@ mod tests { assert_json(&result, "b", serde_json::json!(2)); } + // ── defineConfig with function argument ──────────────────────────── + + #[test] + fn define_config_arrow_block_body() { + let result = parse( + r" + export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + return { + run: { cacheScripts: true }, + plugins: [vue()], + }; + }); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn define_config_arrow_expression_body() { + let result = parse( + r" + export default defineConfig(() => ({ + run: { cacheScripts: true }, + build: { outDir: 'dist' }, + })); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_json(&result, "build", serde_json::json!({ "outDir": "dist" })); + } + + #[test] + fn define_config_function_expression() { + let result = parse( + r" + export default defineConfig(function() { + return { + run: { cacheScripts: true }, + plugins: [react()], + }; + }); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn define_config_arrow_no_return_object() { + // Arrow function that doesn't return an object literal + assert!( + parse_js_ts_config( + r" + export default defineConfig(({ mode }) => { + return someFunction(); + }); + ", + "ts", + ) + .is_none() + ); + } + + #[test] + fn define_config_arrow_multiple_returns() { + // Multiple top-level returns → not analyzable + assert!( + parse_js_ts_config( + r" + export default defineConfig(({ mode }) => { + if (mode === 'production') { + return { run: { cacheScripts: true } }; + } + return { run: { cacheScripts: false } }; + }); + ", + "ts", + ) + .is_none() + ); + } + + #[test] + fn define_config_arrow_empty_body() { + assert!(parse_js_ts_config("export default defineConfig(() => {});", "ts",).is_none()); + } + // ── Not analyzable cases (return None) ────────────────────────────── #[test] From c037f460c09b48ee7c5a1cab1cb71ed5900618a6 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Mar 2026 08:53:50 +0800 Subject: [PATCH 11/14] feat(static-config): resolve indirect exports via top-level identifier lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handles the common pattern where the config object is assigned to a variable before being exported or returned: const config = defineConfig({ ... }); export default config; // ESM indirect export module.exports = config; // CJS indirect export return config; // inside defineConfig(fn) callback Resolution scans top-level VariableDeclarator nodes by name (simple binding identifiers only; destructured patterns are skipped). One level of indirection is supported — chained references (const a = b; export default a) are not resolved and fall back to NAPI as before. Fixes tanstack-start-helloworld's ~1.3s config load time. Co-Authored-By: Claude Sonnet 4.6 --- crates/vite_static_config/src/lib.rs | 192 +++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 13 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 466d4869b9..2802ea2032 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -5,7 +5,7 @@ //! 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, ObjectPropertyKind, Program, Statement}; use oxc_parser::Parser; use oxc_span::SourceType; use rustc_hash::FxHashMap; @@ -110,19 +110,39 @@ fn parse_js_ts_config(source: &str, extension: &str) -> StaticConfig { extract_config_fields(&result.program) } +/// Scan top-level statements for a `const`/`let`/`var` declarator whose simple +/// binding identifier matches `name`, and return a reference to its initializer. +/// +/// Returns `None` if no match is found or the declarator has no initializer. +/// Destructured bindings (object/array patterns) are intentionally skipped. +fn find_top_level_init<'a>(name: &str, stmts: &'a [Statement<'a>]) -> Option<&'a Expression<'a>> { + for stmt in stmts { + let Statement::VariableDeclaration(decl) = stmt else { continue }; + for declarator in &decl.declarations { + let BindingPattern::BindingIdentifier(ident) = &declarator.id else { continue }; + if ident.name == name { + return declarator.init.as_ref(); + } + } + } + None +} + /// 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({ ... })` /// 2. `export default { ... }` -/// 3. `module.exports = defineConfig({ ... })` -/// 4. `module.exports = { ... }` -fn extract_config_fields(program: &Program<'_>) -> StaticConfig { +/// 3. `export default config` where `config` is a top-level variable +/// 4. `module.exports = defineConfig({ ... })` +/// 5. `module.exports = { ... }` +/// 6. `module.exports = config` where `config` is a top-level variable +fn extract_config_fields<'a>(program: &'a Program<'a>) -> StaticConfig { 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, &program.body); } // export default class/function — not analyzable return None; @@ -135,7 +155,7 @@ fn extract_config_fields(program: &Program<'_>) -> StaticConfig { 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, &program.body); } } @@ -148,8 +168,12 @@ fn extract_config_fields(program: &Program<'_>) -> StaticConfig { /// - `defineConfig(() => { return { ... }; })` → extract from return statement /// - `defineConfig(function() { return { ... }; })` → extract from return statement /// - `{ ... }` → extract directly +/// - `identifier` → look up in `stmts`, then extract (one level of indirection only) /// - anything else → not analyzable -fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { +fn extract_config_from_expr<'a>( + expr: &'a Expression<'a>, + stmts: &'a [Statement<'a>], +) -> StaticConfig { let expr = expr.without_parentheses(); match expr { Expression::CallExpression(call) => { @@ -161,15 +185,21 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { match first_arg_expr { Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), Expression::ArrowFunctionExpression(arrow) => { - extract_config_from_function_body(&arrow.body) + extract_config_from_function_body(&arrow.body, stmts) } Expression::FunctionExpression(func) => { - extract_config_from_function_body(func.body.as_ref()?) + extract_config_from_function_body(func.body.as_ref()?, stmts) } _ => None, } } Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), + // Resolve a top-level identifier to its initializer (one level of indirection). + // Pass empty stmts on the recursive call to prevent chaining (const a = b; export default a). + Expression::Identifier(ident) if !stmts.is_empty() => { + let init = find_top_level_init(&ident.name, stmts)?; + extract_config_from_expr(init, &[]) + } _ => None, } } @@ -182,7 +212,13 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { /// /// Returns `None` (not analyzable) if the body contains multiple `return` statements /// (at any nesting depth), since the returned config would depend on runtime control flow. -fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> StaticConfig { +/// +/// `module_stmts` is the program's top-level statement list, used as a fallback when +/// resolving an identifier in a `return ` statement. +fn extract_config_from_function_body<'a>( + body: &'a oxc_ast::ast::FunctionBody<'a>, + module_stmts: &'a [Statement<'a>], +) -> StaticConfig { // Reject functions with multiple returns — the config depends on control flow. if count_returns_in_stmts(&body.statements) > 1 { return None; @@ -192,10 +228,19 @@ fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> S match stmt { Statement::ReturnStatement(ret) => { let arg = ret.argument.as_ref()?; - if let Expression::ObjectExpression(obj) = arg.without_parentheses() { - return Some(extract_object_fields(obj)); + match arg.without_parentheses() { + Expression::ObjectExpression(obj) => return Some(extract_object_fields(obj)), + Expression::Identifier(ident) => { + // Look for the binding in the function body first, then at module level. + let init = find_top_level_init(&ident.name, &body.statements) + .or_else(|| find_top_level_init(&ident.name, module_stmts))?; + if let Expression::ObjectExpression(obj) = init.without_parentheses() { + return Some(extract_object_fields(obj)); + } + return None; + } + _ => return None, } - return None; } Statement::ExpressionStatement(expr_stmt) => { // Concise arrow: `() => ({ ... })` is represented as ExpressionStatement @@ -999,4 +1044,125 @@ mod tests { ); assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } + + // ── Indirect exports (identifier resolution) ───────────────────────── + + #[test] + fn export_default_identifier_object() { + let result = parse( + r" + const config = { run: { cacheScripts: true } }; + export default config; + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn export_default_identifier_define_config() { + // Real-world tanstack-start-helloworld pattern + let result = parse( + r" + import { defineConfig } from 'vite-plus'; + const config = defineConfig({ + run: { cacheScripts: true }, + plugins: [devtools(), nitro()], + }); + export default config; + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn module_exports_identifier_object() { + let result = parse_js_ts_config( + r" + const config = { run: { cache: true } }; + module.exports = config; + ", + "cjs", + ) + .expect("expected analyzable config"); + assert_json(&result, "run", serde_json::json!({ "cache": true })); + } + + #[test] + fn module_exports_identifier_define_config() { + let result = parse_js_ts_config( + r" + const { defineConfig } = require('vite-plus'); + const config = defineConfig({ run: { cacheScripts: true } }); + module.exports = config; + ", + "cjs", + ) + .expect("expected analyzable config"); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn define_config_callback_return_local_identifier() { + let result = parse( + r" + export default defineConfig(({ mode }) => { + const obj = { run: { cacheScripts: true }, plugins: [vue()] }; + return obj; + }); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn define_config_callback_return_module_level_identifier() { + let result = parse( + r" + const shared = { run: { cacheScripts: true } }; + export default defineConfig(() => { + return shared; + }); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn export_default_identifier_undeclared_is_none() { + // Identifier not declared in file — not analyzable + assert!(parse_js_ts_config("export default config;", "ts").is_none()); + } + + #[test] + fn export_default_identifier_no_init_is_none() { + // Variable declared without initializer — not analyzable + assert!( + parse_js_ts_config( + r" + let config; + export default config; + ", + "ts", + ) + .is_none() + ); + } + + #[test] + fn export_default_chained_identifier_is_none() { + // Chained indirection (const a = b) — only one level is resolved + assert!( + parse_js_ts_config( + r" + const inner = { run: {} }; + const config = inner; + export default config; + ", + "ts", + ) + .is_none() + ); + } } From c88dd928242896965c2591fdfde26d95cd4b9411 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Mar 2026 09:16:47 +0800 Subject: [PATCH 12/14] Revert "feat(static-config): resolve indirect exports via top-level identifier lookup" This reverts commit ecc3fc2c12c2b40897d74f3de189b10c98c3f7c4. --- crates/vite_static_config/src/lib.rs | 192 ++------------------------- 1 file changed, 13 insertions(+), 179 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 2802ea2032..466d4869b9 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -5,7 +5,7 @@ //! config like `run` without needing a Node.js runtime. use oxc_allocator::Allocator; -use oxc_ast::ast::{BindingPattern, Expression, ObjectPropertyKind, Program, Statement}; +use oxc_ast::ast::{Expression, ObjectPropertyKind, Program, Statement}; use oxc_parser::Parser; use oxc_span::SourceType; use rustc_hash::FxHashMap; @@ -110,39 +110,19 @@ fn parse_js_ts_config(source: &str, extension: &str) -> StaticConfig { extract_config_fields(&result.program) } -/// Scan top-level statements for a `const`/`let`/`var` declarator whose simple -/// binding identifier matches `name`, and return a reference to its initializer. -/// -/// Returns `None` if no match is found or the declarator has no initializer. -/// Destructured bindings (object/array patterns) are intentionally skipped. -fn find_top_level_init<'a>(name: &str, stmts: &'a [Statement<'a>]) -> Option<&'a Expression<'a>> { - for stmt in stmts { - let Statement::VariableDeclaration(decl) = stmt else { continue }; - for declarator in &decl.declarations { - let BindingPattern::BindingIdentifier(ident) = &declarator.id else { continue }; - if ident.name == name { - return declarator.init.as_ref(); - } - } - } - None -} - /// 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({ ... })` /// 2. `export default { ... }` -/// 3. `export default config` where `config` is a top-level variable -/// 4. `module.exports = defineConfig({ ... })` -/// 5. `module.exports = { ... }` -/// 6. `module.exports = config` where `config` is a top-level variable -fn extract_config_fields<'a>(program: &'a Program<'a>) -> StaticConfig { +/// 3. `module.exports = defineConfig({ ... })` +/// 4. `module.exports = { ... }` +fn extract_config_fields(program: &Program<'_>) -> StaticConfig { 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, &program.body); + return extract_config_from_expr(expr); } // export default class/function — not analyzable return None; @@ -155,7 +135,7 @@ fn extract_config_fields<'a>(program: &'a Program<'a>) -> StaticConfig { m.object().is_specific_id("module") && m.static_property_name() == Some("exports") }) { - return extract_config_from_expr(&assign.right, &program.body); + return extract_config_from_expr(&assign.right); } } @@ -168,12 +148,8 @@ fn extract_config_fields<'a>(program: &'a Program<'a>) -> StaticConfig { /// - `defineConfig(() => { return { ... }; })` → extract from return statement /// - `defineConfig(function() { return { ... }; })` → extract from return statement /// - `{ ... }` → extract directly -/// - `identifier` → look up in `stmts`, then extract (one level of indirection only) /// - anything else → not analyzable -fn extract_config_from_expr<'a>( - expr: &'a Expression<'a>, - stmts: &'a [Statement<'a>], -) -> StaticConfig { +fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { let expr = expr.without_parentheses(); match expr { Expression::CallExpression(call) => { @@ -185,21 +161,15 @@ fn extract_config_from_expr<'a>( match first_arg_expr { Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), Expression::ArrowFunctionExpression(arrow) => { - extract_config_from_function_body(&arrow.body, stmts) + extract_config_from_function_body(&arrow.body) } Expression::FunctionExpression(func) => { - extract_config_from_function_body(func.body.as_ref()?, stmts) + extract_config_from_function_body(func.body.as_ref()?) } _ => None, } } Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), - // Resolve a top-level identifier to its initializer (one level of indirection). - // Pass empty stmts on the recursive call to prevent chaining (const a = b; export default a). - Expression::Identifier(ident) if !stmts.is_empty() => { - let init = find_top_level_init(&ident.name, stmts)?; - extract_config_from_expr(init, &[]) - } _ => None, } } @@ -212,13 +182,7 @@ fn extract_config_from_expr<'a>( /// /// Returns `None` (not analyzable) if the body contains multiple `return` statements /// (at any nesting depth), since the returned config would depend on runtime control flow. -/// -/// `module_stmts` is the program's top-level statement list, used as a fallback when -/// resolving an identifier in a `return ` statement. -fn extract_config_from_function_body<'a>( - body: &'a oxc_ast::ast::FunctionBody<'a>, - module_stmts: &'a [Statement<'a>], -) -> StaticConfig { +fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> StaticConfig { // Reject functions with multiple returns — the config depends on control flow. if count_returns_in_stmts(&body.statements) > 1 { return None; @@ -228,19 +192,10 @@ fn extract_config_from_function_body<'a>( match stmt { Statement::ReturnStatement(ret) => { let arg = ret.argument.as_ref()?; - match arg.without_parentheses() { - Expression::ObjectExpression(obj) => return Some(extract_object_fields(obj)), - Expression::Identifier(ident) => { - // Look for the binding in the function body first, then at module level. - let init = find_top_level_init(&ident.name, &body.statements) - .or_else(|| find_top_level_init(&ident.name, module_stmts))?; - if let Expression::ObjectExpression(obj) = init.without_parentheses() { - return Some(extract_object_fields(obj)); - } - return None; - } - _ => return None, + if let Expression::ObjectExpression(obj) = arg.without_parentheses() { + return Some(extract_object_fields(obj)); } + return None; } Statement::ExpressionStatement(expr_stmt) => { // Concise arrow: `() => ({ ... })` is represented as ExpressionStatement @@ -1044,125 +999,4 @@ mod tests { ); assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } - - // ── Indirect exports (identifier resolution) ───────────────────────── - - #[test] - fn export_default_identifier_object() { - let result = parse( - r" - const config = { run: { cacheScripts: true } }; - export default config; - ", - ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - } - - #[test] - fn export_default_identifier_define_config() { - // Real-world tanstack-start-helloworld pattern - let result = parse( - r" - import { defineConfig } from 'vite-plus'; - const config = defineConfig({ - run: { cacheScripts: true }, - plugins: [devtools(), nitro()], - }); - export default config; - ", - ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - assert_non_static(&result, "plugins"); - } - - #[test] - fn module_exports_identifier_object() { - let result = parse_js_ts_config( - r" - const config = { run: { cache: true } }; - module.exports = config; - ", - "cjs", - ) - .expect("expected analyzable config"); - assert_json(&result, "run", serde_json::json!({ "cache": true })); - } - - #[test] - fn module_exports_identifier_define_config() { - let result = parse_js_ts_config( - r" - const { defineConfig } = require('vite-plus'); - const config = defineConfig({ run: { cacheScripts: true } }); - module.exports = config; - ", - "cjs", - ) - .expect("expected analyzable config"); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - } - - #[test] - fn define_config_callback_return_local_identifier() { - let result = parse( - r" - export default defineConfig(({ mode }) => { - const obj = { run: { cacheScripts: true }, plugins: [vue()] }; - return obj; - }); - ", - ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - assert_non_static(&result, "plugins"); - } - - #[test] - fn define_config_callback_return_module_level_identifier() { - let result = parse( - r" - const shared = { run: { cacheScripts: true } }; - export default defineConfig(() => { - return shared; - }); - ", - ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - } - - #[test] - fn export_default_identifier_undeclared_is_none() { - // Identifier not declared in file — not analyzable - assert!(parse_js_ts_config("export default config;", "ts").is_none()); - } - - #[test] - fn export_default_identifier_no_init_is_none() { - // Variable declared without initializer — not analyzable - assert!( - parse_js_ts_config( - r" - let config; - export default config; - ", - "ts", - ) - .is_none() - ); - } - - #[test] - fn export_default_chained_identifier_is_none() { - // Chained indirection (const a = b) — only one level is resolved - assert!( - parse_js_ts_config( - r" - const inner = { run: {} }; - const config = inner; - export default config; - ", - "ts", - ) - .is_none() - ); - } } From 3d61f6115ad5e41c7ffc5673e6804b9bc44ddd19 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Mar 2026 09:20:09 +0800 Subject: [PATCH 13/14] fix(static-config): spread invalidates previously-seen fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ a: 1, ...x, b: 2 }` — the spread may override `a`, so `a` is now marked NonStatic. Fields declared after the spread (`b`) are unaffected since they win over any spread key. Unknown keys from the spread are still not added to the map. Co-Authored-By: Claude Sonnet 4.6 --- crates/vite_static_config/src/lib.rs | 33 +++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 466d4869b9..9c3cd98fbf 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -262,8 +262,11 @@ fn count_returns_in_stmt(stmt: &Statement<'_>) -> usize { /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as -/// [`FieldValue::NonStatic`]. Spread elements and computed properties -/// are not representable so they are silently skipped (their keys are unknown). +/// [`FieldValue::NonStatic`]. Computed properties are silently skipped (key unknown). +/// +/// Spreads invalidate all fields declared before them: `{ a: 1, ...x, b: 2 }` yields +/// `a: NonStatic` (spread may override it) and `b: Json(2)` (declared after, wins). +/// Unknown keys introduced by the spread are not added to the map. fn extract_object_fields( obj: &oxc_ast::ast::ObjectExpression<'_>, ) -> FxHashMap, FieldValue> { @@ -271,7 +274,10 @@ fn extract_object_fields( for prop in &obj.properties { if prop.is_spread() { - // Spread elements — keys are unknown at static analysis time + // A spread may override any field declared before it. + for value in map.values_mut() { + *value = FieldValue::NonStatic; + } continue; } let ObjectPropertyKind::ObjectProperty(prop) = prop else { @@ -686,14 +692,31 @@ mod tests { } #[test] - fn spread_in_top_level_skipped() { + fn spread_unknown_keys_not_in_map() { + // Keys introduced by the spread are unknown — not added to the map. + // Fields declared after the spread are safe (they win over the spread). let result = parse( r" const base = { x: 1 }; export default { ...base, b: 'ok' } ", ); - // Spread at top level — keys unknown, so not in map at all + assert!(!result.contains_key("x")); + assert_json(&result, "b", serde_json::json!("ok")); + } + + #[test] + fn spread_invalidates_previous_fields() { + // Fields declared before a spread become NonStatic — the spread may override them. + // Fields declared after the spread are unaffected. + let result = parse( + r" + const base = { x: 1 }; + export default { a: 1, run: { cacheScripts: true }, ...base, b: 'ok' } + ", + ); + assert_non_static(&result, "a"); + assert_non_static(&result, "run"); assert!(!result.contains_key("x")); assert_json(&result, "b", serde_json::json!("ok")); } From 9cce0f46e609799c5c28e2d80b01cf1df69d4551 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Mar 2026 09:24:30 +0800 Subject: [PATCH 14/14] fix(static-config): computed keys invalidate previously-seen fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ a: 1, [key]: 2, b: 3 }` — `[key]` could resolve to `'a'` and override it, so `a` is now marked NonStatic. Same logic as spreads. Fields after the computed key are unaffected (they explicitly win). Co-Authored-By: Claude Sonnet 4.6 --- crates/vite_static_config/src/lib.rs | 49 ++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 9c3cd98fbf..b20205e4d6 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -262,22 +262,34 @@ fn count_returns_in_stmt(stmt: &Statement<'_>) -> usize { /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as -/// [`FieldValue::NonStatic`]. Computed properties are silently skipped (key unknown). +/// [`FieldValue::NonStatic`]. /// -/// Spreads invalidate all fields declared before them: `{ a: 1, ...x, b: 2 }` yields -/// `a: NonStatic` (spread may override it) and `b: Json(2)` (declared after, wins). -/// Unknown keys introduced by the spread are not added to the map. +/// Both spreads and computed-key properties invalidate all fields declared before +/// them, because either may resolve to a key that overrides an earlier entry: +/// +/// ```js +/// { a: 1, ...x, b: 2 } // a → NonStatic, b → Json(2) +/// { a: 1, [key]: 2, b: 3 } // a → NonStatic, b → Json(3) +/// ``` +/// +/// Fields declared after such entries are safe (they explicitly override whatever +/// the spread/computed-key produced). Unknown keys are never added to the map. fn extract_object_fields( obj: &oxc_ast::ast::ObjectExpression<'_>, ) -> FxHashMap, FieldValue> { let mut map = FxHashMap::default(); + /// Mark every field accumulated so far as NonStatic. + fn invalidate_previous(map: &mut FxHashMap, FieldValue>) { + for value in map.values_mut() { + *value = FieldValue::NonStatic; + } + } + for prop in &obj.properties { if prop.is_spread() { // A spread may override any field declared before it. - for value in map.values_mut() { - *value = FieldValue::NonStatic; - } + invalidate_previous(&mut map); continue; } let ObjectPropertyKind::ObjectProperty(prop) = prop else { @@ -285,7 +297,8 @@ fn extract_object_fields( }; let Some(key) = prop.key.static_name() else { - // Computed properties — keys are unknown at static analysis time + // A computed key may equal any previously-seen key name. + invalidate_previous(&mut map); continue; }; @@ -722,18 +735,34 @@ mod tests { } #[test] - fn computed_properties_skipped() { + fn computed_key_unknown_not_in_map() { + // The computed key's resolved name is unknown — not added to the map. + // Fields declared after it are safe (they explicitly win). let result = parse( r" const key = 'dynamic'; export default { [key]: 'value', plain: 'ok' } ", ); - // Computed key — not in map at all (key is unknown) assert!(!result.contains_key("dynamic")); assert_json(&result, "plain", serde_json::json!("ok")); } + #[test] + fn computed_key_invalidates_previous_fields() { + // A computed key may resolve to any previously-seen name and override it. + let result = parse( + r" + const key = 'run'; + export default { a: 1, run: { cacheScripts: true }, [key]: 'override', b: 2 } + ", + ); + assert_non_static(&result, "a"); + assert_non_static(&result, "run"); + assert!(!result.contains_key("dynamic")); + assert_json(&result, "b", serde_json::json!(2)); + } + #[test] fn non_static_array_with_spread() { let result = parse(