use super::config_parser;
use super::{Plugin, PluginResult};
use oxc_ast::ast::{
Argument, ArrayExpression, ArrayExpressionElement, CallExpression, Expression,
ImportDeclarationSpecifier, ObjectExpression, Program, Statement,
};
const CONFIG_EXPORTS: &[&str] = &["default"];
const REACT_COMPILER_BABEL_PLUGIN: &str = "babel-plugin-react-compiler";
const VITE_REACT_PLUGIN_SOURCE: &str = "@vitejs/plugin-react";
const ROLLDOWN_BABEL_PLUGIN_SOURCE: &str = "@rolldown/plugin-babel";
#[derive(Default)]
struct ReactCompilerPluginLocals {
react_calls: Vec<String>,
babel_calls: Vec<String>,
}
fn additional_data_entry_pattern(
root: &std::path::Path,
source: &fallow_extract::css::CssImportSource,
) -> Option<String> {
let normalized = source.normalized.trim_start_matches("./");
if normalized.is_empty()
|| normalized.starts_with('/')
|| is_additional_data_package_import(root, source, normalized)
{
return None;
}
Some(normalized.to_string())
}
fn additional_data_package_name(
root: &std::path::Path,
source: &fallow_extract::css::CssImportSource,
) -> Option<String> {
let normalized = source.normalized.trim_start_matches("./");
is_additional_data_package_import(root, source, normalized)
.then(|| crate::resolve::extract_package_name(&source.raw))
}
fn is_additional_data_package_import(
root: &std::path::Path,
source: &fallow_extract::css::CssImportSource,
normalized: &str,
) -> bool {
let raw = source.raw.as_str();
if raw.starts_with('.') || raw.starts_with('/') || raw.contains(':') {
return false;
}
if local_style_candidate_exists(root, normalized) {
return false;
}
true
}
fn local_style_candidate_exists(root: &std::path::Path, normalized: &str) -> bool {
let path = std::path::Path::new(normalized);
let exact = root.join(path);
if exact.is_file() {
return true;
}
let has_style_ext = path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"css" | "scss" | "sass" | "less" | "stylus"
)
});
if has_style_ext {
return false;
}
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty());
let with_parent =
|name: &str| parent.map_or_else(|| root.join(name), |parent| root.join(parent).join(name));
["scss", "sass", "css", "less", "stylus"].iter().any(|ext| {
with_parent(&format!("{file_name}.{ext}")).is_file()
|| with_parent(&format!("_{file_name}.{ext}")).is_file()
|| root.join(path).join(format!("_index.{ext}")).is_file()
|| root.join(path).join(format!("index.{ext}")).is_file()
})
}
define_plugin!(
struct VitePlugin => "vite",
enablers: &["vite", "rolldown-vite"],
entry_patterns: &[
"src/main.{ts,tsx,js,jsx}",
"src/index.{ts,tsx,js,jsx}",
"index.html",
],
config_patterns: &["vite.config.{ts,js,mts,mjs}"],
always_used: &["vite.config.{ts,js,mts,mjs}"],
tooling_dependencies: &["vite", "@vitejs/plugin-react", "@vitejs/plugin-vue"],
// Vite plugins create virtual modules with `virtual:` prefix
// (e.g., `virtual:pwa-register`, `virtual:emoji-mart-lang-importer`)
virtual_module_prefixes: &["virtual:"],
// Under --include-entry-exports, the default export of vite.config.* is the
// entry: Vite's CLI consumes it. Marking it framework-used prevents the
// false-positive in #282 (mirrors the vitest fix in #271).
used_exports: [("vite.config.{ts,js,mts,mjs}", CONFIG_EXPORTS)],
resolve_config(config_path, source, root) {
let mut result = PluginResult::default();
let imports = config_parser::extract_imports(source, config_path);
for imp in &imports {
let dep = crate::resolve::extract_package_name(imp);
result.referenced_dependencies.push(dep);
}
result.referenced_dependencies.extend(
config_parser::extract_vite_react_babel_dependencies(source, config_path),
);
for dep in extract_react_compiler_plugin_dependencies(source, config_path) {
result.referenced_dependencies.push(dep);
}
for (find, replacement) in
config_parser::extract_config_aliases(source, config_path, &["resolve", "alias"])
{
if let Some(normalized) =
config_parser::normalize_config_path(&replacement, config_path, root)
{
result.path_aliases.push((find, normalized));
}
}
// Vitest test config is commonly embedded in vite.config.* via
// defineConfig({ test: {...}, resolve: { alias } }). The Vitest plugin
// never sees this file (its config_patterns are vitest.config.* /
// vitest.workspace.* only), so extract the test-block + projects aliases
// here. Top-level resolve.alias above stays path-alias-only (no
// mock-file entry seeding / dependency credit) to keep pure-Vite
// behavior unchanged. See crate::plugins::test_alias.
super::test_alias::apply_test_block_aliases(&mut result, source, config_path, root);
// build.rollupOptions.input → entry points (string, array, or object)
let rollup_input = config_parser::extract_config_string_or_array(
source,
config_path,
&["build", "rollupOptions", "input"],
);
result.extend_entry_patterns(rollup_input);
// build.lib.entry → entry points (string or array)
let lib_entry = config_parser::extract_config_string_or_array(
source,
config_path,
&["build", "lib", "entry"],
);
result.extend_entry_patterns(lib_entry);
// optimizeDeps.include → referenced dependencies
let optimize_include = config_parser::extract_config_string_array(
source,
config_path,
&["optimizeDeps", "include"],
);
for dep in &optimize_include {
result
.referenced_dependencies
.push(crate::resolve::extract_package_name(dep));
}
// optimizeDeps.exclude → referenced dependencies
let optimize_exclude = config_parser::extract_config_string_array(
source,
config_path,
&["optimizeDeps", "exclude"],
);
for dep in &optimize_exclude {
result
.referenced_dependencies
.push(crate::resolve::extract_package_name(dep));
}
// ssr.external → referenced dependencies
let ssr_external =
config_parser::extract_config_string_array(source, config_path, &["ssr", "external"]);
for dep in &ssr_external {
result
.referenced_dependencies
.push(crate::resolve::extract_package_name(dep));
}
// ssr.noExternal → referenced dependencies
let ssr_no_external =
config_parser::extract_config_string_array(source, config_path, &["ssr", "noExternal"]);
for dep in &ssr_no_external {
result
.referenced_dependencies
.push(crate::resolve::extract_package_name(dep));
}
// css.preprocessorOptions.{scss,sass,less,stylus}.additionalData →
// SCSS / Sass strings injected at the top of every preprocessed file.
// The string body itself is not parsed, but `@use` / `@import` /
// `@forward` / `@plugin` directives inside it reference real files that no source
// file imports directly. Seed those files as entry points so they do
// not get reported as `unused-files`. Function-form `additionalData`
// is skipped (out of static-analysis scope) and stylesheet content is
// the only string treated as preprocessor source. Specifiers are
// stripped of their leading `./` because entry patterns are matched
// against project-relative paths via globset (which does not normalize
// `./` prefixes). See issue #195 (Case A).
for preprocessor in ["scss", "sass", "less", "stylus"] {
let body = config_parser::extract_config_string_or_array(
source,
config_path,
&["css", "preprocessorOptions", preprocessor, "additionalData"],
);
let is_scss_like = matches!(preprocessor, "scss" | "sass");
for blob in body {
for spec in fallow_extract::css::extract_css_import_sources(&blob, is_scss_like) {
if let Some(dep) = additional_data_package_name(root, &spec) {
result.referenced_dependencies.push(dep);
}
if let Some(pattern) = additional_data_entry_pattern(root, &spec) {
result.push_entry_pattern(pattern);
}
}
}
}
result
},
);
fn extract_react_compiler_plugin_dependencies(source: &str, path: &std::path::Path) -> Vec<String> {
config_parser::extract_from_source(source, path, |program| {
let config = config_parser::find_config_object_pub(program)?;
let plugins = config_parser::property_expr(config, "plugins")
.and_then(config_parser::array_expression)?;
let locals = collect_react_compiler_plugin_locals(program);
let mut deps = Vec::new();
for element in &plugins.elements {
let Some(Expression::CallExpression(call)) = element.as_expression() else {
continue;
};
if is_local_call(call, &locals.react_calls) {
collect_from_plugin_call_option_path(call, &["babel", "plugins"], &mut deps);
} else if is_local_call(call, &locals.babel_calls) {
collect_from_plugin_call_option_path(call, &["plugins"], &mut deps);
collect_from_plugin_call_option_path(call, &["babel", "plugins"], &mut deps);
}
}
(!deps.is_empty()).then_some(deps)
})
.unwrap_or_default()
}
fn collect_react_compiler_plugin_locals(program: &Program<'_>) -> ReactCompilerPluginLocals {
let mut locals = ReactCompilerPluginLocals::default();
for stmt in &program.body {
let Statement::ImportDeclaration(decl) = stmt else {
continue;
};
let Some(specifiers) = &decl.specifiers else {
continue;
};
for specifier in specifiers {
match specifier {
ImportDeclarationSpecifier::ImportDefaultSpecifier(default)
if decl.source.value == VITE_REACT_PLUGIN_SOURCE =>
{
push_unique(&mut locals.react_calls, default.local.name.to_string());
}
ImportDeclarationSpecifier::ImportSpecifier(specifier)
if decl.source.value == VITE_REACT_PLUGIN_SOURCE
&& specifier.imported.name() == "react" =>
{
push_unique(&mut locals.react_calls, specifier.local.name.to_string());
}
ImportDeclarationSpecifier::ImportDefaultSpecifier(default)
if decl.source.value == ROLLDOWN_BABEL_PLUGIN_SOURCE =>
{
push_unique(&mut locals.babel_calls, default.local.name.to_string());
}
ImportDeclarationSpecifier::ImportSpecifier(specifier)
if decl.source.value == ROLLDOWN_BABEL_PLUGIN_SOURCE
&& specifier.imported.name() == "babel" =>
{
push_unique(&mut locals.babel_calls, specifier.local.name.to_string());
}
_ => {}
}
}
}
locals
}
fn collect_from_plugin_call_option_path(
call: &CallExpression<'_>,
option_path: &[&str],
deps: &mut Vec<String>,
) {
let Some(options) = call
.arguments
.first()
.and_then(Argument::as_expression)
.and_then(config_parser::object_expression)
else {
return;
};
let Some(plugins) = nested_array_expression(options, option_path) else {
return;
};
for plugin_name in collect_babel_plugin_names(plugins) {
if super::babel::resolve_babel_plugin_name(&plugin_name) == REACT_COMPILER_BABEL_PLUGIN {
push_unique(deps, REACT_COMPILER_BABEL_PLUGIN.to_string());
}
}
}
fn nested_array_expression<'a>(
obj: &'a ObjectExpression<'a>,
path: &[&str],
) -> Option<&'a ArrayExpression<'a>> {
let mut current_obj = obj;
for (index, key) in path.iter().enumerate() {
let expr = config_parser::property_expr(current_obj, key)?;
if index == path.len() - 1 {
return config_parser::array_expression(expr);
}
current_obj = config_parser::object_expression(expr)?;
}
None
}
fn collect_babel_plugin_names(plugins: &ArrayExpression<'_>) -> Vec<String> {
plugins
.elements
.iter()
.filter_map(|element| {
let expr = element.as_expression()?;
config_parser::expression_to_string(expr).or_else(|| {
let tuple = config_parser::array_expression(expr)?;
tuple
.elements
.first()
.and_then(ArrayExpressionElement::as_expression)
.and_then(config_parser::expression_to_string)
})
})
.collect()
}
fn is_local_call(call: &CallExpression<'_>, locals: &[String]) -> bool {
matches!(
&call.callee,
Expression::Identifier(identifier)
if locals.iter().any(|local| local == identifier.name.as_str())
)
}
fn push_unique<T: Eq>(items: &mut Vec<T>, item: T) {
if !items.contains(&item) {
items.push(item);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_config_ssr_external() {
let source = r#"
export default {
ssr: {
external: ["lodash", "express"],
noExternal: ["my-ui-lib"]
}
};
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("vite.config.ts"),
source,
std::path::Path::new("/project"),
);
let deps = &result.referenced_dependencies;
assert!(deps.contains(&"lodash".to_string()));
assert!(deps.contains(&"express".to_string()));
assert!(deps.contains(&"my-ui-lib".to_string()));
}
#[test]
fn resolve_config_optimize_deps_exclude() {
let source = r#"
export default {
optimizeDeps: {
include: ["react"],
exclude: ["@my/heavy-dep"]
}
};
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("vite.config.ts"),
source,
std::path::Path::new("/project"),
);
let deps = &result.referenced_dependencies;
assert!(deps.contains(&"react".to_string()));
assert!(deps.contains(&"@my/heavy-dep".to_string()));
}
#[test]
fn resolve_config_credits_react_babel_plugin_dependencies() {
let source = r#"
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["module:@preact/signals-react-transform", {}]],
presets: ["@babel/preset-react"],
},
}),
],
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("vite.config.ts"),
source,
std::path::Path::new("/project"),
);
let deps = &result.referenced_dependencies;
assert!(
deps.contains(&"@preact/signals-react-transform".to_string()),
"React Babel plugin dependency should be credited: {deps:?}"
);
assert!(
deps.contains(&"@babel/preset-react".to_string()),
"React Babel preset dependency should be credited: {deps:?}"
);
}
#[test]
fn resolve_config_extracts_aliases() {
let source = r#"
import { defineConfig } from 'vite';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))
}
}
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert_eq!(
result.path_aliases,
vec![("@".to_string(), "src".to_string())]
);
}
#[test]
fn resolve_config_extracts_embedded_test_alias_and_project_resolve_alias() {
// The common defineConfig({ test: {...}, resolve: { alias } }) shape in
// vite.config.ts: the Vite plugin must extract the Vitest test-block
// aliases (the Vitest plugin never sees vite.config.ts).
let source = r#"
import { defineConfig } from 'vite';
export default defineConfig({
resolve: { alias: { "@": "./src" } },
test: {
alias: { vscode: "./test/mock/vscode.ts" },
projects: [
{ test: { name: "browser" }, resolve: { alias: { "test-alias-from-vite": "./mock/to.ts" } } }
]
}
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert!(
result
.path_aliases
.contains(&("vscode".to_string(), "test/mock/vscode.ts".to_string())),
"test.alias in vite.config must be extracted: {:?}",
result.path_aliases
);
assert!(
result
.path_aliases
.contains(&("test-alias-from-vite".to_string(), "mock/to.ts".to_string())),
"test.projects[*].resolve.alias in vite.config must be extracted: {:?}",
result.path_aliases
);
// The top-level resolve.alias `@`->`./src` stays handled by Vite's own
// A-only extraction (path alias, no entry seeding).
assert!(
result
.path_aliases
.contains(&("@".to_string(), "src".to_string())),
"top-level resolve.alias unchanged: {:?}",
result.path_aliases
);
}
#[test]
fn resolve_config_additional_data_marks_package_imports_as_referenced_dependencies() {
let tmp = tempfile::tempdir().expect("create temp dir");
let source = r#"
import { defineConfig } from 'vite';
export default defineConfig({
css: {
preprocessorOptions: {
scss: { additionalData: `@use "bootstrap/scss/functions"; @use "bulma";` },
},
},
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(&tmp.path().join("vite.config.ts"), source, tmp.path());
assert!(
result
.referenced_dependencies
.contains(&"bootstrap".to_string()),
"additionalData package imports should credit the package dependency"
);
assert!(
result
.referenced_dependencies
.contains(&"bulma".to_string()),
"bare additionalData package imports should credit the package dependency"
);
assert!(
!result
.entry_patterns
.iter()
.any(|rule| rule.pattern == "bootstrap/scss/functions"),
"package imports should not be seeded as project entry globs"
);
assert!(
!result
.entry_patterns
.iter()
.any(|rule| rule.pattern == "bulma"),
"bare package imports should not be seeded as project entry globs"
);
}
#[test]
fn resolve_config_rollup_input_evaluates_path_helpers() {
// Issue #604: rollupOptions.input values written as path-helper calls
// (resolve(__dirname, "..."), path.resolve(...), join(...),
// import.meta.dirname equivalents) must be evaluated to project-relative
// entry patterns. CSS entries are preserved like any other entry.
let source = r#"
import { resolve, join } from "node:path";
import path from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
build: {
rollupOptions: {
input: {
app: resolve(__dirname, "src/app.ts"),
modal: path.resolve(__dirname, "src/modal.ts"),
tabs: join(__dirname, "src/tabs.ts"),
timetable: resolve(import.meta.dirname, "src/timetable.ts"),
styles: resolve(__dirname, "src/index.css"),
},
},
},
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
let patterns: Vec<&str> = result
.entry_patterns
.iter()
.map(|rule| rule.pattern.as_str())
.collect();
for expected in [
"src/app.ts",
"src/modal.ts",
"src/tabs.ts",
"src/timetable.ts",
"src/index.css",
] {
assert!(
patterns.contains(&expected),
"rollupOptions.input path-helper entry {expected} should be extracted: {patterns:?}"
);
}
}
#[test]
fn resolve_config_lib_entry_evaluates_path_helper() {
// build.lib.entry as a single top-level path-helper call.
let source = r#"
import { resolve } from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, "src/index.ts"),
},
},
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert!(
result
.entry_patterns
.iter()
.any(|rule| rule.pattern == "src/index.ts"),
"build.lib.entry path-helper call should be extracted: {:?}",
result.entry_patterns
);
}
#[test]
fn resolve_config_react_babel_plugin_references_react_compiler_dependency() {
let source = r#"
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert!(
result
.referenced_dependencies
.contains(&REACT_COMPILER_BABEL_PLUGIN.to_string())
);
}
#[test]
fn resolve_config_react_babel_plugin_tuple_references_react_compiler_dependency() {
let source = r#"
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["react-compiler", { target: "19" }]],
},
}),
],
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert!(
result
.referenced_dependencies
.contains(&REACT_COMPILER_BABEL_PLUGIN.to_string())
);
}
#[test]
fn resolve_config_rolldown_babel_plugin_references_react_compiler_dependency() {
let source = r#"
import { defineConfig } from "vite";
import { babel } from "@rolldown/plugin-babel";
export default defineConfig({
plugins: [
babel({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert!(
result
.referenced_dependencies
.contains(&REACT_COMPILER_BABEL_PLUGIN.to_string())
);
}
#[test]
fn resolve_config_unrelated_string_does_not_reference_react_compiler_dependency() {
let source = r#"
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
notes: "babel-plugin-react-compiler",
babel: {
plugins: [["other-plugin", { note: "babel-plugin-react-compiler" }]],
},
}),
],
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert!(
!result
.referenced_dependencies
.contains(&REACT_COMPILER_BABEL_PLUGIN.to_string())
);
}
#[test]
fn resolve_config_requires_imported_vite_plugin_call_provenance() {
let source = r#"
import { defineConfig } from "vite";
function react(options) {
return options;
}
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert!(
!result
.referenced_dependencies
.contains(&REACT_COMPILER_BABEL_PLUGIN.to_string())
);
}
#[test]
fn resolve_config_local_react_compiler_preset_call_does_not_reference_dependency() {
let source = r#"
import { defineConfig } from "vite";
function reactCompilerPreset() {
return {};
}
export default defineConfig({
plugins: [reactCompilerPreset()],
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(
std::path::Path::new("/project/vite.config.ts"),
source,
std::path::Path::new("/project"),
);
assert!(
!result
.referenced_dependencies
.contains(&REACT_COMPILER_BABEL_PLUGIN.to_string())
);
}
#[test]
fn resolve_config_additional_data_keeps_existing_local_style_entries() {
let tmp = tempfile::tempdir().expect("create temp dir");
std::fs::create_dir_all(tmp.path().join("src/styles")).expect("create styles dir");
std::fs::write(tmp.path().join("src/styles/_tokens.scss"), "$primary: red;")
.expect("write local partial");
let source = r#"
import { defineConfig } from 'vite';
export default defineConfig({
css: {
preprocessorOptions: {
scss: { additionalData: `@use "src/styles/tokens";` },
},
},
});
"#;
let plugin = VitePlugin;
let result = plugin.resolve_config(&tmp.path().join("vite.config.ts"), source, tmp.path());
assert!(
result
.entry_patterns
.iter()
.any(|rule| rule.pattern == "src/styles/tokens"),
"existing local style references should remain entry patterns"
);
assert!(
!result.referenced_dependencies.contains(&"src".to_string()),
"local style references should not be misclassified as packages"
);
}
}