mod layout;
mod plan;
mod reach;
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
pub use fallow_mutation::{
FunctionRef, Mutant, MutantId, MutationError, Operator, RESERVED_PREFIX, SourceKind,
};
pub use fallow_output::DiffIndex;
pub use layout::{instrument_source, is_component_file};
use crate::EngineError;
use crate::module_graph::{ExportTestReach, RetainedModuleGraph};
use crate::session::AnalysisSession;
use crate::source::ModuleInfo;
use fallow_types::results::AnalysisResults;
pub const RUNNER_CONFIG_MODULES: [&str; 18] = [
"vitest.config.ts",
"vitest.config.mts",
"vitest.config.cts",
"vitest.config.js",
"vitest.config.mjs",
"vitest.config.cjs",
"vite.config.ts",
"vite.config.mts",
"vite.config.cts",
"vite.config.js",
"vite.config.mjs",
"vite.config.cjs",
"vitest.workspace.ts",
"vitest.workspace.mts",
"vitest.workspace.cts",
"vitest.workspace.js",
"vitest.workspace.mjs",
"vitest.workspace.cjs",
];
#[derive(Debug, Clone)]
pub enum MutationScope {
ChangedSinceBase(DiffIndex),
All,
Paths(Vec<PathBuf>),
}
#[derive(Debug, Clone)]
pub struct MutationPlanRequest {
pub scope: MutationScope,
pub operators: Option<Vec<Operator>>,
pub allow_side_effects: bool,
pub max_mutants: Option<usize>,
pub skip_component_files: bool,
}
impl MutationPlanRequest {
#[must_use]
pub const fn new(scope: MutationScope) -> Self {
Self {
scope,
operators: None,
allow_side_effects: false,
max_mutants: None,
skip_component_files: false,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FrameworkConfigExports {
files: BTreeMap<String, BTreeSet<String>>,
config_files: BTreeSet<String>,
}
impl FrameworkConfigExports {
pub fn insert(&mut self, file: impl Into<String>, export: impl Into<String>) {
self.files
.entry(file.into())
.or_default()
.insert(export.into());
}
pub fn insert_config_file(&mut self, file: impl Into<String>) {
let file = file.into();
self.insert(file.clone(), DEFAULT_EXPORT);
self.config_files.insert(file);
}
#[must_use]
pub fn is_config_file(&self, file: &str) -> bool {
self.config_files.contains(file)
}
#[must_use]
pub fn exports_for(&self, file: &str) -> Option<&BTreeSet<String>> {
self.files.get(file)
}
}
#[derive(Clone, Copy)]
pub struct MutationPlanInputs<'a> {
pub root: &'a Path,
pub graph: &'a RetainedModuleGraph,
pub modules: &'a [ModuleInfo],
pub results: &'a AnalysisResults,
pub framework_config_exports: Option<&'a FrameworkConfigExports>,
pub setup_files: &'a [PathBuf],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum PruneReason {
DeadCode,
Generated,
SideEffectSink,
FrameworkConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(tag = "status", content = "reason", rename_all = "kebab-case")]
pub enum MutantDisposition {
Planned,
Pending,
NoCoverage,
Pruned(PruneReason),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FunctionFacts {
pub name: Option<String>,
pub span: (u32, u32),
pub line: u32,
pub end_line: u32,
pub cyclomatic: Option<u16>,
pub in_diff: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlannedMutant {
pub mutant: Mutant,
pub file: String,
pub rank: Option<u32>,
pub disposition: MutantDisposition,
pub function: Option<FunctionFacts>,
pub test_reach: Option<MutantTestReach>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MutantTestReach {
pub reach: ExportTestReach,
pub export_name: Option<String>,
pub tests: Vec<String>,
pub mocked_targets: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlannedFile {
pub path: String,
pub source_sha256: String,
pub instrumented_code: String,
pub static_test_files: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SkipReason {
Unreadable,
Stale,
Parse,
ReservedName,
TooLarge,
NotAnalyzed,
RunnerCannotLoad,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SkippedFile {
pub path: String,
pub reason: SkipReason,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct PruneCounts {
pub dead_code: usize,
pub generated: usize,
pub side_effect_sink: usize,
pub framework_config: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct MutationPlanCounts {
pub total: usize,
pub planned: usize,
pub pending: usize,
pub no_coverage: usize,
pub pruned: PruneCounts,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MutationPlan {
pub files: Vec<PlannedFile>,
pub mutants: Vec<PlannedMutant>,
pub test_files: Vec<String>,
#[serde(skip)]
pub reach_files: Vec<String>,
#[serde(skip)]
pub test_file_reach: BTreeMap<String, Vec<u32>>,
#[serde(skip)]
pub support_files: Vec<String>,
pub counts: MutationPlanCounts,
pub no_test_files: bool,
pub framework_config_checked: bool,
pub skipped_files: Vec<SkippedFile>,
pub unmatched_paths: Vec<String>,
}
impl MutationPlan {
pub fn test_file_reach_paths<'a>(&'a self, test_file: &str) -> impl Iterator<Item = &'a str> {
self.test_file_reach
.get(test_file)
.into_iter()
.flatten()
.filter_map(|&index| self.reach_files.get(index as usize))
.map(String::as_str)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MutationPlanError {
Analysis(EngineError),
MissingArtifact(&'static str),
Plugins(String),
}
impl std::fmt::Display for MutationPlanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Analysis(error) => write!(f, "the analysis failed: {error}"),
Self::MissingArtifact(name) => {
write!(
f,
"the analysis did not keep the {name} for the mutation plan"
)
}
Self::Plugins(message) => write!(f, "the plugin stage failed: {message}"),
}
}
}
impl std::error::Error for MutationPlanError {}
impl From<EngineError> for MutationPlanError {
fn from(error: EngineError) -> Self {
Self::Analysis(error)
}
}
#[must_use]
pub fn plan_mutations(
inputs: &MutationPlanInputs<'_>,
request: &MutationPlanRequest,
) -> MutationPlan {
plan::build(inputs, request)
}
pub fn plan_mutations_for_session(
session: &AnalysisSession,
request: &MutationPlanRequest,
) -> Result<MutationPlan, MutationPlanError> {
plan_mutations_for_session_with(session, request, |_| false)
}
pub fn plan_mutations_for_session_with(
session: &AnalysisSession,
request: &MutationPlanRequest,
skip_components: impl FnOnce(&MutationPlan) -> bool,
) -> Result<MutationPlan, MutationPlanError> {
let artifacts = session.analyze_dead_code_with_artifacts(true, true)?;
let graph = artifacts
.graph
.as_ref()
.ok_or(MutationPlanError::MissingArtifact("module graph"))?;
let modules = artifacts
.modules
.as_deref()
.ok_or(MutationPlanError::MissingArtifact("parsed modules"))?;
let inventory = crate::list_inventory::collect_listing_inventory(session, false)
.map_err(|error| MutationPlanError::Plugins(error.message().to_owned()))?;
let framework_config_exports = framework_config_exports(session, graph, &inventory);
let setup_files = inventory.plugins.backend().setup_files();
let inputs = MutationPlanInputs {
root: session.root(),
graph,
modules,
results: &artifacts.results,
framework_config_exports: Some(&framework_config_exports),
setup_files: &setup_files,
};
let plan = plan_mutations(&inputs, request);
let has_component = plan
.mutants
.iter()
.any(|mutant| is_component_file(&mutant.file));
if request.skip_component_files || !has_component || !skip_components(&plan) {
return Ok(plan);
}
let request = MutationPlanRequest {
skip_component_files: true,
..request.clone()
};
Ok(plan_mutations(&inputs, &request))
}
fn framework_config_exports(
session: &AnalysisSession,
graph: &RetainedModuleGraph,
inventory: &crate::list_inventory::ListingInventory,
) -> FrameworkConfigExports {
let backend = inventory.plugins.backend();
let names = backend.used_export_names();
let active = backend.active_plugins();
let external = session
.config()
.external_plugins
.iter()
.filter(|plugin| active.contains(&plugin.name))
.flat_map(|plugin| {
plugin
.config_patterns
.iter()
.chain(&plugin.always_used)
.cloned()
});
let patterns: Vec<String> = backend
.config_patterns()
.into_iter()
.chain(backend.always_used_patterns())
.chain(external)
.collect();
let config_files = config_file_globs(&patterns);
let mut exports = FrameworkConfigExports::default();
for node in &graph.as_graph().modules {
let rel = crate::module_graph::relative_key_path(&node.path, session.root());
for name in names.names_for(&rel) {
exports.insert(rel.clone(), name);
}
if config_files.is_match(&rel) {
exports.insert_config_file(rel);
}
}
exports
}
const DEFAULT_EXPORT: &str = "default";
const CONFIG_NAME_MARKERS: [&str; 2] = ["config", ".conf."];
fn is_tool_config_glob(pattern: &str) -> bool {
let name = pattern
.rsplit('/')
.next()
.unwrap_or(pattern)
.to_ascii_lowercase();
if CONFIG_NAME_MARKERS
.iter()
.any(|marker| name.contains(marker))
{
return true;
}
let stem = name.split(['.', '{']).find(|part| !part.is_empty());
stem.is_some_and(|stem| stem.ends_with("rc"))
}
fn config_file_globs(patterns: &[String]) -> globset::GlobSet {
let mut builder = globset::GlobSetBuilder::new();
for pattern in patterns
.iter()
.filter(|pattern| is_tool_config_glob(pattern))
{
let anywhere = if pattern.starts_with("**/") {
pattern.clone()
} else {
format!("**/{pattern}")
};
for candidate in [pattern.as_str(), anywhere.as_str()] {
if let Ok(glob) = globset::Glob::new(candidate) {
builder.add(glob);
}
}
}
builder
.build()
.unwrap_or_else(|_| globset::GlobSet::empty())
}