mod collect;
mod emit;
mod id;
mod operator;
use serde::Serialize;
pub use operator::Operator;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SourceKind {
Js,
Jsx,
Ts,
Tsx,
}
impl SourceKind {
pub fn from_path(path: &str) -> Option<Self> {
let extension = path.rsplit_once('.')?.1;
match extension {
"js" | "mjs" | "cjs" => Some(Self::Js),
"jsx" => Some(Self::Jsx),
"ts" | "mts" | "cts" => Some(Self::Ts),
"tsx" => Some(Self::Tsx),
_ => None,
}
}
pub const fn source_type(self) -> oxc_span::SourceType {
match self {
Self::Js => oxc_span::SourceType::unambiguous().with_jsx(true),
Self::Jsx => oxc_span::SourceType::jsx(),
Self::Ts => oxc_span::SourceType::ts(),
Self::Tsx => oxc_span::SourceType::tsx(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MutationOptions {
pub operators: Vec<Operator>,
pub ranges: Option<Vec<(u32, u32)>>,
}
impl Default for MutationOptions {
fn default() -> Self {
Self {
operators: Operator::ALL.to_vec(),
ranges: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct MutantId(pub String);
impl MutantId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for MutantId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FunctionRef {
pub name: Option<String>,
pub span: (u32, u32),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Mutant {
pub id: MutantId,
pub operator: Operator,
pub span: (u32, u32),
pub line: u32,
pub column: u32,
pub original: String,
pub replacement: String,
pub enclosing_function: Option<FunctionRef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SpliceSegment {
pub generated_start: u32,
pub original_start: u32,
pub len: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct SpliceMap {
pub segments: Vec<SpliceSegment>,
}
impl SpliceMap {
pub fn original_offset(&self, generated: u32) -> Option<u32> {
let index = self
.segments
.partition_point(|segment| segment.generated_start <= generated);
let segment = self.segments.get(index.checked_sub(1)?)?;
let delta = generated - segment.generated_start;
(delta < segment.len).then_some(segment.original_start + delta)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Instrumented {
pub mutants: Vec<Mutant>,
pub code: String,
pub map: SpliceMap,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MutationError {
Parse {
messages: Vec<String>,
},
TooLarge,
ReservedName {
name: String,
},
}
impl std::fmt::Display for MutationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse { messages } => {
write!(f, "the source does not parse: {}", messages.join("; "))
}
Self::TooLarge => {
f.write_str("the source or the instrumented output is larger than 4 GiB")
}
Self::ReservedName { name } => write!(
f,
"the source declares `{name}`, a name that the mutation prelude reserves"
),
}
}
}
impl std::error::Error for MutationError {}
const fn is_identifier_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || byte >= 0x80
}
pub const RESERVED_PREFIX: &str = "__fallow_m_";
pub const ACTIVE_FUNCTION: &str = "__fallow_m_a";
pub const HIT_FUNCTION: &str = "__fallow_m_c";
pub fn instrument(
source: &str,
path: &str,
source_type: SourceKind,
options: &MutationOptions,
) -> Result<Instrumented, MutationError> {
if u32::try_from(source.len()).is_err() {
return Err(MutationError::TooLarge);
}
let allocator = oxc_allocator::Allocator::default();
let parsed = oxc_parser::Parser::new(&allocator, source, source_type.source_type()).parse();
if parsed.fatal_error || parsed.diagnostics.has_errors() {
let messages = parsed.diagnostics.iter().map(ToString::to_string).collect();
return Err(MutationError::Parse { messages });
}
if let Some(name) = reserved_binding(&parsed.program) {
return Err(MutationError::ReservedName { name });
}
let collected = collect::collect(&parsed.program, source);
let insertions = header_insertions(&parsed.program, source);
let line_starts = line_starts(source);
let mut mutants = Vec::new();
let mut emit_sites = Vec::new();
let mut seen_ids = std::collections::BTreeSet::new();
for site in collected.sites {
let kept = select_candidates(source, &site, &collected.statement_starts, options);
if kept.is_empty() {
continue;
}
let mut emit_mutants = Vec::with_capacity(kept.len());
for candidate in kept {
let id = unique_id(path, source, &site, &candidate, &mut seen_ids);
let (line, column) = line_column(&line_starts, candidate.region.0);
emit_mutants.push(emit::EmitMutant {
id: id.0.clone(),
region: candidate.region,
replacement: candidate.replacement.clone(),
});
mutants.push(Mutant {
id,
operator: candidate.operator,
span: candidate.region,
line,
column,
original: slice(source, candidate.region).to_owned(),
replacement: candidate.replacement,
enclosing_function: site.function.clone(),
});
}
emit_sites.push(emit::EmitSite {
span: site.span,
mutants: emit_mutants,
});
}
mutants.sort_by_key(|mutant| mutant.span);
let (code, map) = emit::emit(
source,
&emit_sites,
&collected.statement_starts,
&insertions,
);
if u32::try_from(code.len()).is_err() {
return Err(MutationError::TooLarge);
}
Ok(Instrumented { mutants, code, map })
}
fn reserved_binding(program: &oxc_ast::ast::Program<'_>) -> Option<String> {
use oxc_ast_visit::Visit;
#[derive(Default)]
struct Finder {
found: Option<String>,
}
impl<'a> Visit<'a> for Finder {
fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) {
let name = it.name.as_str();
if self.found.is_none() && (name == ACTIVE_FUNCTION || name == HIT_FUNCTION) {
self.found = Some(name.to_owned());
}
}
}
let mut finder = Finder::default();
finder.visit_program(program);
finder.found
}
const PRELUDE: &str = "function __fallow_m_a(id) { const m = globalThis.__fallowMutation; return m ? m.a(id) : false; } function __fallow_m_c(id) { const m = globalThis.__fallowMutation; if (m) m.c(id); }";
const TS_NOCHECK: &str = "// @ts-nocheck";
fn header_insertions(program: &oxc_ast::ast::Program<'_>, source: &str) -> Vec<emit::Insertion> {
let offset = leading_end(program, source);
let line_break_follows = source
.get(offset as usize..)
.is_some_and(|rest| rest.starts_with(['\n', '\r', '\u{2028}', '\u{2029}']));
let nocheck = if offset == 0 {
format!("{TS_NOCHECK}\n")
} else if program.directives.is_empty() || line_break_follows {
format!("\n{TS_NOCHECK}")
} else {
format!("\n{TS_NOCHECK}\n")
};
let Some(last) = program.directives.last() else {
let text = if offset == 0 {
format!("{nocheck}{PRELUDE}\n")
} else {
format!("{nocheck}\n{PRELUDE}")
};
return vec![emit::Insertion { offset, text }];
};
let end = last.span.end;
let terminated = source
.get(..end as usize)
.is_some_and(|text| text.ends_with(';'));
let separator = if terminated { "" } else { ";" };
vec![
emit::Insertion {
offset,
text: nocheck,
},
emit::Insertion {
offset: end,
text: format!("{separator}{PRELUDE}"),
},
]
}
fn leading_end(program: &oxc_ast::ast::Program<'_>, source: &str) -> u32 {
use oxc_span::GetSpan;
let first_token = program
.directives
.first()
.map(|directive| directive.span.start)
.or_else(|| program.body.first().map(|statement| statement.span().start))
.unwrap_or(source.len() as u32);
let hashbang_end = program
.hashbang
.as_ref()
.map_or(0, |hashbang| hashbang.span.end);
program
.comments
.iter()
.take_while(|comment| comment.span.end <= first_token)
.map(|comment| comment.span.end)
.last()
.map_or(hashbang_end, |end| end.max(hashbang_end))
}
fn slice(source: &str, span: (u32, u32)) -> &str {
source
.get(span.0 as usize..span.1 as usize)
.unwrap_or_default()
}
fn select_candidates(
source: &str,
site: &collect::Site,
statement_starts: &std::collections::BTreeSet<u32>,
options: &MutationOptions,
) -> Vec<collect::Candidate> {
let original = slice(source, site.span);
let mut texts = std::collections::BTreeSet::new();
let mut kept = Vec::new();
for candidate in &site.candidates {
if !options.operators.contains(&candidate.operator) {
continue;
}
let (start, end) = candidate.region;
let in_range = options
.ranges
.as_ref()
.is_none_or(|ranges| ranges.iter().any(|&(from, to)| start < to && from < end));
if !in_range {
continue;
}
let replacement = guard_statement_start(source, statement_starts, candidate);
let mutated = format!(
"{}{}{}",
slice(source, (site.span.0, start)),
replacement,
slice(source, (end, site.span.1)),
);
if mutated == original || !texts.insert(mutated) {
continue;
}
kept.push(collect::Candidate {
operator: candidate.operator,
region: candidate.region,
replacement,
});
}
kept
}
const ASI_HAZARDS: [char; 6] = ['(', '[', '`', '+', '-', '/'];
fn starts_with_asi_hazard(text: &str) -> bool {
let hazard = text.starts_with(ASI_HAZARDS);
hazard && !text.starts_with("++") && !text.starts_with("--")
}
fn guard_statement_start(
source: &str,
statement_starts: &std::collections::BTreeSet<u32>,
candidate: &collect::Candidate,
) -> String {
let (start, end) = candidate.region;
let guarded = statement_starts.contains(&start)
&& starts_with_asi_hazard(&candidate.replacement)
&& !starts_with_asi_hazard(slice(source, (start, end)));
if !guarded {
return candidate.replacement.clone();
}
let joins = source
.as_bytes()
.get((start as usize).wrapping_sub(1))
.is_some_and(|&byte| is_identifier_byte(byte));
let prefix = if joins { " void " } else { "void " };
format!("{prefix}{}", candidate.replacement)
}
fn unique_id(
path: &str,
source: &str,
site: &collect::Site,
candidate: &collect::Candidate,
seen: &mut std::collections::BTreeSet<MutantId>,
) -> MutantId {
let anchor_start = site.anchor.0;
let input = id::IdInput {
path,
anchor_text: slice(source, site.anchor),
relative: (
candidate.region.0.saturating_sub(anchor_start),
candidate.region.1.saturating_sub(anchor_start),
),
operator: candidate.operator,
replacement: &candidate.replacement,
};
let mut occurrence = 0;
loop {
let id = id::mutant_id(&input, occurrence);
if seen.insert(id.clone()) {
return id;
}
occurrence += 1;
}
}
fn line_starts(source: &str) -> Vec<u32> {
std::iter::once(0)
.chain(
source
.match_indices('\n')
.map(|(index, _)| index as u32 + 1),
)
.collect()
}
fn line_column(line_starts: &[u32], offset: u32) -> (u32, u32) {
let index = line_starts
.partition_point(|&start| start <= offset)
.saturating_sub(1);
let start = line_starts.get(index).copied().unwrap_or(0);
(index as u32 + 1, offset - start)
}