use std::path::{Component, Path, PathBuf};
use fallow_config::JsxImportSourceRule;
use oxc_ast::ast::{Expression, ObjectExpression};
use super::config_parser;
use super::test_alias;
use super::{PathRule, Plugin, PluginResult};
pub struct VitestPlugin;
const ENABLERS: &[&str] = &["vitest", "vite-plus"];
const SCRIPT_CONFIG_BINARIES: &[&str] = &["vitest"];
const ENTRY_PATTERNS: &[&str] = &[
VITEST_DEFAULT_INCLUDE,
"**/__tests__/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}",
VITEST_DEFAULT_BENCH_INCLUDE,
];
const CONFIG_PATTERNS: &[&str] = &[
"**/vitest.config.{ts,js,mts,mjs,cts,cjs}",
"**/vitest.workspace.{ts,js}",
"**/vite.config.{ts,js,mts,mjs,cts,cjs}",
];
const ALWAYS_USED: &[&str] = &[
"vitest.config.{ts,js,mts,mjs,cts,cjs}",
"vitest.setup.{ts,js}",
"vitest.workspace.{ts,js}",
"**/src/setupTests.{ts,tsx,js,jsx}",
"**/src/test-setup.{ts,tsx,js,jsx}",
];
const TOOLING_DEPENDENCIES: &[&str] = &["vitest"];
const CONFIG_EXPORTS: &[&str] = &["default"];
const FIXTURE_PATTERNS: &[&str] = &[
"**/__fixtures__/**/*.{ts,tsx,js,jsx,json}",
"**/fixtures/**/*.{ts,tsx,js,jsx,json}",
];
const BUILTIN_REPORTERS: &[&str] = &[
"default",
"verbose",
"dot",
"json",
"tap",
"tap-flat",
"hanging-process",
"github-actions",
"blob",
"basic",
"junit",
"html",
];
const VITEST_CONFIG_FILES: &[&str] = &[
"vitest.config.ts",
"vitest.config.js",
"vitest.config.mts",
"vitest.config.mjs",
"vitest.config.cts",
"vitest.config.cjs",
"vite.config.ts",
"vite.config.js",
"vite.config.mts",
"vite.config.mjs",
"vite.config.cts",
"vite.config.cjs",
];
impl Plugin for VitestPlugin {
fn name(&self) -> &'static str {
"vitest"
}
fn enablers(&self) -> &'static [&'static str] {
ENABLERS
}
fn is_enabled_with_deps(&self, deps: &[String], root: &Path) -> bool {
let enablers = self.enablers();
if enablers.iter().any(|e| deps.iter().any(|d| d == e)) {
return true;
}
VITEST_CONFIG_FILES.iter().any(|f| root.join(f).exists())
}
fn entry_patterns(&self) -> &'static [&'static str] {
ENTRY_PATTERNS
}
fn config_patterns(&self) -> &'static [&'static str] {
CONFIG_PATTERNS
}
fn always_used(&self) -> &'static [&'static str] {
ALWAYS_USED
}
fn tooling_dependencies(&self) -> &'static [&'static str] {
TOOLING_DEPENDENCIES
}
fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
vec![
("vitest.config.{ts,js,mts,mjs,cts,cjs}", CONFIG_EXPORTS),
("vitest.workspace.{ts,js}", CONFIG_EXPORTS),
]
}
fn fixture_glob_patterns(&self) -> &'static [&'static str] {
FIXTURE_PATTERNS
}
fn script_config_binaries(&self) -> &'static [&'static str] {
SCRIPT_CONFIG_BINARIES
}
fn resolve_config(&self, config_path: &Path, source: &str, root: &Path) -> PluginResult {
self.resolve_config_in_project(config_path, source, root, root)
}
fn resolve_config_in_project(
&self,
config_path: &Path,
source: &str,
root: &Path,
project_root: &Path,
) -> PluginResult {
let run_dir = vitest_run_dir(config_path, root);
resolve_vitest_config(config_path, source, root, project_root, &run_dir)
}
fn resolve_script_config(
&self,
config_path: &Path,
source: &str,
root: &Path,
project_root: &Path,
) -> PluginResult {
resolve_vitest_config(config_path, source, root, project_root, root)
}
}
fn resolve_vitest_config(
config_path: &Path,
source: &str,
root: &Path,
project_root: &Path,
run_dir: &Path,
) -> PluginResult {
let mut result = PluginResult::default();
let imports = config_parser::extract_imports(source, config_path);
add_import_dependencies(&mut result, &imports);
result
.referenced_dependencies
.extend(config_parser::extract_vite_plugin_option_dependencies(
source,
config_path,
));
apply_vitest_aliases(&mut result, source, config_path, root);
let dirs = VitestDirs::read(source, config_path, run_dir);
if !is_shadowed_vite_config(config_path) {
let jsx = JsxContext {
inherit_by_default: !source.contains("projects")
|| vitest_inherits_by_default(config_path, root),
};
apply_vitest_includes(&mut result, source, config_path, root, run_dir, &dirs);
add_inline_project_includes(
&mut result,
source,
config_path,
(root, project_root),
(run_dir, &dirs.root),
jsx,
);
add_vitest_jsx_import_sources(&mut result, source, config_path, project_root, jsx);
add_vitest_project_config_files(
&mut result,
source,
config_path,
(root, project_root),
jsx,
);
}
add_vitest_setup_files(&mut result, source, config_path, &dirs.root);
add_vitest_environment_dependency(&mut result, source, config_path);
add_vitest_reporter_dependencies(&mut result, source, config_path, root, &dirs.root);
add_vitest_coverage_dependency(&mut result, source, config_path);
add_vitest_typecheck_dependency(&mut result, source, config_path);
add_vitest_browser_dependency(&mut result, source, config_path);
result
}
fn add_import_dependencies(result: &mut PluginResult, imports: &[String]) {
for imp in imports {
let dep = crate::resolve::extract_package_name(imp);
result.referenced_dependencies.push(dep);
}
}
fn apply_vitest_aliases(result: &mut PluginResult, source: &str, config_path: &Path, root: &Path) {
test_alias::apply_test_block_aliases(result, source, config_path, root);
for (find, replacement, is_bare) in
config_parser::extract_config_aliases_kinded(source, config_path, &["resolve", "alias"])
{
test_alias::process_test_alias(result, &find, &replacement, is_bare, config_path, root);
}
test_alias::apply_workspace_array_aliases(result, source, config_path, root);
test_alias::debug_unreachable_config(source, config_path);
}
fn apply_vitest_includes(
result: &mut PluginResult,
source: &str,
config_path: &Path,
root: &Path,
run_dir: &Path,
dirs: &VitestDirs,
) {
result.accumulate_config_entry_patterns = true;
let Some(includes) = push_config_includes(result, source, config_path, root, dirs) else {
return;
};
if !includes.is_empty() && dir_prefix(run_dir, root).is_some_and(|prefix| prefix.is_empty()) {
result.replace_entry_patterns = true;
if !includes.bench {
push_vitest_include_entries(
result,
&[VITEST_DEFAULT_BENCH_INCLUDE],
&includes.prefix,
root,
);
}
}
}
struct VitestDirs {
root: PathBuf,
include: PathBuf,
}
impl VitestDirs {
fn read(source: &str, config_path: &Path, run_dir: &Path) -> Self {
let value = |path: &[&str]| config_parser::extract_config_string(source, config_path, path);
let root = value(&["test", "root"])
.or_else(|| value(&["root"]))
.map_or_else(|| run_dir.to_path_buf(), |dir| lexical_join(run_dir, &dir));
let include =
value(&["test", "dir"]).map_or_else(|| root.clone(), |dir| lexical_join(&root, &dir));
Self { root, include }
}
}
struct ConfigIncludes {
prefix: String,
test: bool,
bench: bool,
}
impl ConfigIncludes {
fn is_empty(&self) -> bool {
!self.test
}
}
fn push_config_includes(
result: &mut PluginResult,
source: &str,
config_path: &Path,
root: &Path,
dirs: &VitestDirs,
) -> Option<ConfigIncludes> {
let prefix = dir_prefix(&dirs.include, root)?;
let test_includes =
config_parser::extract_config_string_array(source, config_path, &["test", "include"]);
let bench_includes = config_parser::extract_config_string_array(
source,
config_path,
&["test", "benchmark", "include"],
);
push_vitest_include_entries(result, &test_includes, &prefix, root);
push_vitest_include_entries(result, &bench_includes, &prefix, root);
Some(ConfigIncludes {
prefix,
test: !test_includes.is_empty(),
bench: !bench_includes.is_empty(),
})
}
fn dir_prefix(dir: &Path, root: &Path) -> Option<String> {
dir.strip_prefix(root)
.ok()
.map(config_parser::path_to_config_string)
}
fn prefixed_glob(prefix: &str, glob: &str) -> String {
if prefix.is_empty() {
glob.to_string()
} else {
format!("{prefix}/{glob}")
}
}
fn add_inline_project_includes(
result: &mut PluginResult,
source: &str,
config_path: &Path,
(root, project_root): (&Path, &Path),
(run_dir, vitest_root): (&Path, &Path),
jsx: JsxContext,
) {
let Some((scopes, base_root)) =
config_parser::extract_from_source(source, config_path, |program| {
let config = config_parser::find_config_object(program)?;
config_parser::property_object(config, "test")
.and_then(|test| config_parser::property_expr(test, "projects"))
.and_then(config_parser::array_expression)?;
let scopes = collect_project_scopes(config, config_path, project_root, jsx).0;
Some((scopes, ProjectScope::read(config).root))
})
else {
return;
};
for scope in scopes {
let base = if scope.root == base_root {
run_dir
} else {
vitest_root
};
let (Some(prefix), Some(groups)) = (dir_prefix(base, root), scope.glob_groups(base)) else {
continue;
};
for (includes, excludes) in groups {
let excludes: Vec<String> = excludes
.iter()
.map(|glob| prefixed_glob(&prefix, glob))
.collect();
result.entry_patterns.extend(includes.iter().map(|include| {
PathRule::new(prefixed_glob(&prefix, include)).with_excluded_globs(&excludes)
}));
}
}
}
fn vitest_run_dir(config_path: &Path, root: &Path) -> PathBuf {
let discovered = config_path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| {
name.strip_prefix("vitest.config.")
.or_else(|| name.strip_prefix("vite.config."))
})
.is_some_and(|extension| VITEST_CONFIG_EXTENSIONS.contains(&extension));
match config_path.parent() {
Some(dir) if discovered && dir.starts_with(root) => dir.to_path_buf(),
_ => root.to_path_buf(),
}
}
fn push_vitest_include_entries<S: AsRef<str>>(
result: &mut PluginResult,
includes: &[S],
prefix: &str,
root: &Path,
) {
result
.entry_patterns
.extend(includes.iter().filter_map(|include| {
let include = include.as_ref();
let glob = if include.starts_with('!') {
vitest_extglob(include)
} else {
scoped_glob(prefix, include, root)?
};
Some(PathRule::from_extglob(&glob))
}));
}
const VITEST_DEFAULT_INCLUDE: &str = "**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs,mts,cts}";
const VITEST_DEFAULT_BENCH_INCLUDE: &str = "**/*.{bench,benchmark}.{js,jsx,ts,tsx,mjs,cjs,mts,cts}";
const VITEST_DEFAULT_EXCLUDE: &[&str] = &["**/node_modules/**", "**/.git/**"];
const DEFAULT_JSX_IMPORT_SOURCE: &str = "react";
const VITEST_INHERIT_BY_DEFAULT_MAJOR: u64 = 5;
const VITEST_CONFIG_EXTENSIONS: &[&str] = &["ts", "js", "mts", "mjs", "cts", "cjs"];
const VITEST_CONFIG_NAMES: &[&str] = &[
"vitest.config.ts",
"vitest.config.js",
"vitest.config.mts",
"vitest.config.mjs",
"vitest.config.cts",
"vitest.config.cjs",
];
const MAX_PROJECT_GLOB_CONFIGS: usize = 64;
#[derive(Clone, Copy)]
struct JsxContext {
inherit_by_default: bool,
}
fn is_shadowed_vite_config(config_path: &Path) -> bool {
let (Some(name), Some(config_dir)) = (
config_path.file_name().and_then(|name| name.to_str()),
config_path.parent(),
) else {
return false;
};
if !name.starts_with("vite.config.") {
return false;
}
let Some(vitest_config) = VITEST_CONFIG_NAMES
.iter()
.map(|name| config_dir.join(name))
.find(|path| path.is_file())
else {
return false;
};
let Ok(vitest_source) = std::fs::read_to_string(&vitest_config) else {
return true;
};
!vitest_source.contains("vite.config")
}
fn vitest_inherits_by_default(config_path: &Path, root: &Path) -> bool {
let dirs: Vec<&Path> = config_path
.ancestors()
.skip(1)
.take_while(|dir| dir.starts_with(root))
.collect();
let major = dirs
.iter()
.find_map(|dir| installed_vitest_major(dir))
.or_else(|| dirs.iter().find_map(|dir| declared_vitest_major(dir)));
major.is_none_or(|major| major >= VITEST_INHERIT_BY_DEFAULT_MAJOR)
}
fn installed_vitest_major(dir: &Path) -> Option<u64> {
let manifest = dir.join("node_modules/vitest/package.json");
let source = std::fs::read_to_string(manifest).ok()?;
let value: serde_json::Value = serde_json::from_str(&source).ok()?;
leading_major(value.get("version")?.as_str()?)
}
fn declared_vitest_major(dir: &Path) -> Option<u64> {
let pkg = fallow_config::PackageJson::load(&dir.join("package.json")).ok()?;
let range = [
&pkg.dependencies,
&pkg.dev_dependencies,
&pkg.peer_dependencies,
&pkg.optional_dependencies,
]
.into_iter()
.flatten()
.find_map(|deps| deps.get("vitest"))?;
range_max_major(range)
}
fn range_max_major(range: &str) -> Option<u64> {
let range = range.strip_prefix("npm:vitest@").unwrap_or(range);
let mut max = None;
for alternative in range.split("||") {
let alternative = alternative.trim();
if alternative.starts_with('>') {
return None;
}
let major = leading_major(alternative)?;
max = max.max(Some(major));
}
max
}
fn leading_major(version: &str) -> Option<u64> {
let digits: String = version
.trim_start_matches(['^', '~', '=', 'v', ' '])
.chars()
.take_while(char::is_ascii_digit)
.collect();
digits.parse().ok()
}
#[derive(Clone, Default)]
struct JsxTransform {
automatic: Option<bool>,
import_source: Option<String>,
}
enum JsxRuntime {
None,
Default,
Source(String),
}
impl JsxTransform {
fn read(config: &ObjectExpression<'_>) -> Self {
if let Some(oxc) = config_parser::property_expr(config, "oxc") {
if matches!(oxc, Expression::BooleanLiteral(value) if !value.value) {
return Self {
automatic: Some(false),
import_source: None,
};
}
return config_parser::object_expression(oxc)
.and_then(|oxc| config_parser::property_expr(oxc, "jsx"))
.map_or_else(Self::default, Self::read_oxc_jsx);
}
config_parser::property_object(config, "esbuild").map_or_else(Self::default, |esbuild| {
Self {
automatic: config_parser::property_string(esbuild, "jsx")
.map(|mode| mode == "automatic"),
import_source: config_parser::property_string(esbuild, "jsxImportSource"),
}
})
}
fn read_oxc_jsx(jsx: &Expression<'_>) -> Self {
let Some(jsx) = config_parser::object_expression(jsx) else {
return Self {
automatic: config_parser::expression_to_string(jsx).map(|_| false),
import_source: None,
};
};
Self {
automatic: config_parser::property_string(jsx, "runtime")
.map(|runtime| runtime != "classic"),
import_source: config_parser::property_string(jsx, "importSource"),
}
}
fn over(self, base: &Self) -> Self {
Self {
automatic: self.automatic.or(base.automatic),
import_source: self.import_source.or_else(|| base.import_source.clone()),
}
}
fn runtime(self) -> JsxRuntime {
if self.automatic == Some(false) {
return JsxRuntime::None;
}
match self.import_source {
None => JsxRuntime::Default,
Some(source) if source.is_empty() => JsxRuntime::None,
Some(source) => JsxRuntime::Source(source),
}
}
}
#[derive(Clone, Default)]
struct ProjectScope {
jsx: JsxTransform,
include: Vec<String>,
exclude: Vec<String>,
root: Option<String>,
dir: Option<String>,
}
impl ProjectScope {
fn read(config: &ObjectExpression<'_>) -> Self {
let test = config_parser::property_object(config, "test");
let patterns = |key: &str| {
test.and_then(|test| config_parser::property_expr(test, key))
.map(config_parser::expression_to_string_or_array)
.unwrap_or_default()
};
Self {
jsx: JsxTransform::read(config),
include: patterns("include"),
exclude: patterns("exclude"),
root: test
.and_then(|test| config_parser::property_string(test, "root"))
.or_else(|| config_parser::property_string(config, "root")),
dir: test.and_then(|test| config_parser::property_string(test, "dir")),
}
}
fn over(self, base: &Self) -> Self {
let concat = |base: &[String], own: Vec<String>| {
let mut merged = base.to_vec();
merged.extend(own);
merged
};
Self {
jsx: self.jsx.over(&base.jsx),
include: concat(&base.include, self.include),
exclude: concat(&base.exclude, self.exclude),
root: self.root.or_else(|| base.root.clone()),
dir: self.dir.or_else(|| base.dir.clone()),
}
}
fn glob_groups(&self, config_dir: &Path) -> Option<Vec<(Vec<String>, Vec<String>)>> {
let (includes, exclude) = self.globs(config_dir)?;
let mut plain = Vec::new();
let mut groups = Vec::new();
for include in includes {
let rewritten = fallow_config::extglob::rewrite_include(&include);
if rewritten.excludes.is_empty() {
plain.push(rewritten.include);
} else {
let mut own_exclude = exclude.clone();
own_exclude.extend(rewritten.excludes);
groups.push((vec![rewritten.include], own_exclude));
}
}
if !plain.is_empty() {
groups.insert(0, (plain, exclude));
}
Some(groups)
}
fn globs(&self, config_dir: &Path) -> Option<(Vec<String>, Vec<String>)> {
let mut base = lexical_join(config_dir, self.root.as_deref().unwrap_or("."));
if let Some(dir) = &self.dir {
base = lexical_join(&base, dir);
}
let prefix = config_parser::path_to_config_string(base.strip_prefix(config_dir).ok()?);
let scoped = |pattern: &str| scoped_glob(&prefix, pattern, config_dir);
let (negated, positive): (Vec<&String>, Vec<&String>) = self
.include
.iter()
.partition(|pattern| pattern.starts_with('!'));
let mut include: Vec<String> = positive
.iter()
.filter_map(|pattern| scoped(pattern.as_str()))
.collect();
if positive.is_empty() {
include.extend(scoped(VITEST_DEFAULT_INCLUDE));
}
let exclude_patterns: Vec<&str> = if self.exclude.is_empty() {
VITEST_DEFAULT_EXCLUDE.to_vec()
} else {
self.exclude
.iter()
.map(String::as_str)
.filter(|pattern| !pattern.starts_with('!'))
.collect()
};
let mut exclude = Vec::new();
for pattern in exclude_patterns
.into_iter()
.chain(negated.iter().map(|pattern| &pattern[1..]))
{
let Some(glob) =
scoped(pattern).and_then(|glob| fallow_config::extglob::rewrite_exclude(&glob))
else {
continue;
};
let trimmed = glob.trim_end_matches('/');
exclude.push(trimmed.to_string());
if !trimmed.ends_with("/**") && trimmed != "**" {
exclude.push(format!("{trimmed}/**"));
}
}
Some((include, exclude))
}
}
fn scoped_glob(prefix: &str, pattern: &str, config_dir: &Path) -> Option<String> {
if pattern.starts_with('/') {
let relative = Path::new(pattern).strip_prefix(config_dir).ok()?;
return Some(vitest_extglob(&config_parser::path_to_config_string(
relative,
)));
}
let glob = vitest_extglob(pattern);
if prefix.is_empty() {
return Some(glob);
}
Some(format!("{prefix}/{glob}"))
}
fn lexical_join(dir: &Path, relative: &str) -> PathBuf {
let mut joined = dir.to_path_buf();
for component in Path::new(relative).components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
joined.pop();
}
other => joined.push(other.as_os_str()),
}
}
joined
}
enum ProjectBase {
DeclaringConfig,
None,
File(PathBuf),
}
fn project_base(
project: &ObjectExpression<'_>,
config_path: &Path,
jsx: JsxContext,
) -> ProjectBase {
let Some(extends) = config_parser::property_expr(project, "extends") else {
return if jsx.inherit_by_default {
ProjectBase::DeclaringConfig
} else {
ProjectBase::None
};
};
if let Expression::BooleanLiteral(value) = extends {
return if value.value {
ProjectBase::DeclaringConfig
} else {
ProjectBase::None
};
}
let (Some(path), Some(config_dir)) = (
config_parser::expression_to_string(extends),
config_path.parent(),
) else {
return ProjectBase::DeclaringConfig;
};
let path = lexical_join(config_dir, &path);
if path == lexical_join(config_dir, &config_path.to_string_lossy()) {
return ProjectBase::DeclaringConfig;
}
ProjectBase::File(path)
}
fn collect_project_scopes(
root: &ObjectExpression<'_>,
config_path: &Path,
project_root: &Path,
jsx: JsxContext,
) -> (Vec<ProjectScope>, Vec<PathBuf>) {
let root_scope = ProjectScope::read(root);
let projects = config_parser::property_object(root, "test")
.and_then(|test| config_parser::property_expr(test, "projects"))
.and_then(config_parser::array_expression);
let Some(projects) = projects else {
return (vec![root_scope], Vec::new());
};
let mut scopes = Vec::new();
let mut extended_files = Vec::new();
for project in projects
.elements
.iter()
.filter_map(|element| element.as_expression())
.filter_map(config_parser::object_expression)
{
let own = ProjectScope::read(project);
let scope = match project_base(project, config_path, jsx) {
ProjectBase::DeclaringConfig => own.over(&root_scope),
ProjectBase::None => own,
ProjectBase::File(path) if !config_parser::is_inside_root(&path, project_root) => own,
ProjectBase::File(path) => match read_config_scope(&path) {
Some(base) => {
extended_files.push(path);
own.over(&base)
}
None => own,
},
};
scopes.push(scope);
}
(scopes, extended_files)
}
fn read_config_scope(path: &Path) -> Option<ProjectScope> {
let source = std::fs::read_to_string(path).ok()?;
config_parser::extract_from_source(&source, path, |program| {
config_parser::find_config_object(program).map(ProjectScope::read)
})
}
fn add_vitest_jsx_import_sources(
result: &mut PluginResult,
source: &str,
config_path: &Path,
project_root: &Path,
jsx: JsxContext,
) {
let Some(config_dir) = config_path.parent() else {
return;
};
let Some((scopes, extended_files)) =
config_parser::extract_from_source(source, config_path, |program| {
config_parser::find_config_object(program)
.map(|config| collect_project_scopes(config, config_path, project_root, jsx))
})
else {
return;
};
for path in extended_files {
if !result.setup_files.contains(&path) {
result.setup_files.push(path);
}
}
for scope in scopes {
let Some(groups) = scope.glob_groups(config_dir) else {
continue;
};
let rules = |source: &str| -> Vec<JsxImportSourceRule> {
groups
.iter()
.map(|(include, exclude)| JsxImportSourceRule {
source: source.to_string(),
config_dir: config_dir.to_path_buf(),
include: include.clone(),
exclude: exclude.clone(),
})
.collect()
};
match scope.jsx.runtime() {
JsxRuntime::None => {}
JsxRuntime::Default => result
.jsx_package_credits
.extend(rules(DEFAULT_JSX_IMPORT_SOURCE)),
JsxRuntime::Source(source) if !is_path_source(&source) => result
.referenced_dependencies
.push(crate::resolve::extract_package_name(&source)),
JsxRuntime::Source(source) => result.jsx_import_sources.extend(rules(&source)),
}
}
}
fn add_vitest_project_config_files(
result: &mut PluginResult,
source: &str,
config_path: &Path,
(root, project_root): (&Path, &Path),
jsx: JsxContext,
) {
let Some(config_dir) = config_path.parent() else {
return;
};
let entries =
config_parser::extract_config_string_or_array(source, config_path, &["test", "projects"]);
for entry in entries {
for path in project_config_paths(&entry, config_dir, project_root) {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if !is_vitest_config_name(name)
|| name.starts_with("vitest.config.")
|| name.starts_with("vite.config.")
{
continue;
}
let Ok(project_source) = std::fs::read_to_string(&path) else {
continue;
};
add_vitest_jsx_import_sources(result, &project_source, &path, project_root, jsx);
add_project_file_includes(result, &project_source, &path, root);
if !result.setup_files.contains(&path) {
result.setup_files.push(path);
}
}
}
}
fn add_project_file_includes(result: &mut PluginResult, source: &str, path: &Path, root: &Path) {
let Some(project_dir) = path.parent() else {
return;
};
let dirs = VitestDirs::read(source, path, project_dir);
if let Some(includes) = push_config_includes(result, source, path, root, &dirs)
&& includes.is_empty()
{
push_vitest_include_entries(result, &[VITEST_DEFAULT_INCLUDE], &includes.prefix, root);
}
add_vitest_setup_files(result, source, path, &dirs.root);
}
fn project_config_paths(entry: &str, config_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
let relative = entry.strip_prefix("./").unwrap_or(entry);
if entry.contains('{') || relative.starts_with("**") {
return Vec::new();
}
if !entry.contains(['*', '?', '[']) {
let path = lexical_join(config_dir, entry);
return if config_parser::is_inside_root(&path, project_root) {
vec![path]
} else {
Vec::new()
};
}
if !config_parser::glob_base_is_inside_root(config_dir, relative, project_root) {
return Vec::new();
}
super::project_glob::expand(config_dir, relative, project_root)
.paths
.into_iter()
.filter(|path| {
path.is_file()
&& config_parser::is_inside_root(path, project_root)
&& !super::project_glob::is_in_node_modules(path, project_root)
})
.map(|path| config_parser::lexical_normalize(&path))
.take(MAX_PROJECT_GLOB_CONFIGS)
.collect()
}
fn is_vitest_config_name(name: &str) -> bool {
let Some(rest) = name
.strip_prefix("vitest.")
.or_else(|| name.strip_prefix("vite."))
else {
return false;
};
if rest.starts_with("config.") {
return true;
}
rest.split_once(".config.").is_some_and(|(middle, _)| {
!middle.is_empty()
&& middle
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
})
}
fn is_path_source(source: &str) -> bool {
source.starts_with('.') || source.starts_with('/')
}
fn vitest_extglob(pattern: &str) -> String {
let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
let mut out = String::with_capacity(pattern.len() + 2);
let mut previous = None;
let mut escaped = false;
for character in pattern.chars() {
if character == '(' && !escaped && !matches!(previous, Some('@' | '?' | '+' | '*' | '!')) {
out.push('@');
}
escaped = character == '\\' && !escaped;
previous = Some(character);
out.push(character);
}
out
}
fn add_vitest_setup_files(
result: &mut PluginResult,
source: &str,
config_path: &Path,
vitest_root: &Path,
) {
let mut setup_files =
config_parser::extract_config_string_or_array(source, config_path, &["test", "setupFiles"]);
setup_files.extend(config_parser::extract_config_array_nested_string_or_array(
source,
config_path,
&["test", "projects"],
&["test", "setupFiles"],
));
for f in &setup_files {
result.setup_files.push(lexical_join(vitest_root, f));
}
let mut global_setup = config_parser::extract_config_string_or_array(
source,
config_path,
&["test", "globalSetup"],
);
global_setup.extend(config_parser::extract_config_array_nested_string_or_array(
source,
config_path,
&["test", "projects"],
&["test", "globalSetup"],
));
for f in &global_setup {
result.setup_files.push(lexical_join(vitest_root, f));
}
}
fn add_vitest_environment_dependency(result: &mut PluginResult, source: &str, config_path: &Path) {
let Some(env) =
config_parser::extract_config_string(source, config_path, &["test", "environment"])
else {
return;
};
match env.as_str() {
"node" => {}
"jsdom" | "happy-dom" => {
result.referenced_dependencies.push(env.clone());
super::credit_environment_optional_peers(&env, result);
}
_ => {
let credited = super::credit_config_value(
super::config_value_credits::CreditSurface::VitestBuiltinEnvironment,
&env,
result,
);
if !credited {
result
.referenced_dependencies
.push(format!("vitest-environment-{env}"));
result.referenced_dependencies.push(env);
}
}
}
}
fn add_vitest_reporter_dependencies(
result: &mut PluginResult,
source: &str,
config_path: &Path,
root: &Path,
vitest_root: &Path,
) {
let reporters = config_parser::extract_config_nested_shallow_strings(
source,
config_path,
&["test"],
"reporters",
);
for reporter in &reporters {
if BUILTIN_REPORTERS.contains(&reporter.as_str()) {
continue;
}
if is_path_source(reporter) {
let path = lexical_join(vitest_root, reporter);
if path.starts_with(root) && !result.setup_files.contains(&path) {
result.setup_files.push(path);
}
continue;
}
let dep = crate::resolve::extract_package_name(reporter);
result.referenced_dependencies.push(dep);
}
}
fn add_vitest_coverage_dependency(result: &mut PluginResult, source: &str, config_path: &Path) {
if let Some(provider) =
config_parser::extract_config_string(source, config_path, &["test", "coverage", "provider"])
&& !matches!(provider.as_str(), "v8" | "istanbul")
{
result
.referenced_dependencies
.push(format!("@vitest/coverage-{provider}"));
result.referenced_dependencies.push(provider);
}
}
fn add_vitest_typecheck_dependency(result: &mut PluginResult, source: &str, config_path: &Path) {
if let Some(checker) =
config_parser::extract_config_string(source, config_path, &["test", "typecheck", "checker"])
&& !matches!(checker.as_str(), "tsc")
{
result.referenced_dependencies.push(checker);
}
}
fn add_vitest_browser_dependency(result: &mut PluginResult, source: &str, config_path: &Path) {
if let Some(provider) =
config_parser::extract_config_string(source, config_path, &["test", "browser", "provider"])
&& !matches!(provider.as_str(), "preview")
{
result
.referenced_dependencies
.push("@vitest/browser".to_string());
result.referenced_dependencies.push(provider);
}
}
#[cfg(test)]
mod tests;