1mod blocks;
18mod collect;
19mod emit;
20mod id;
21mod operator;
22mod regex;
23
24use serde::Serialize;
25
26pub use blocks::{ScriptBlock, ScriptRole, instrument_blocks};
27pub use operator::Operator;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum SourceKind {
33 Js,
36 Jsx,
38 Ts,
40 Tsx,
42}
43
44impl SourceKind {
45 pub fn from_path(path: &str) -> Option<Self> {
47 let extension = path.rsplit_once('.')?.1;
48 match extension {
49 "js" | "mjs" | "cjs" => Some(Self::Js),
50 "jsx" => Some(Self::Jsx),
51 "ts" | "mts" | "cts" => Some(Self::Ts),
52 "tsx" => Some(Self::Tsx),
53 _ => None,
54 }
55 }
56
57 pub const fn source_type(self) -> oxc_span::SourceType {
59 match self {
60 Self::Js => oxc_span::SourceType::unambiguous().with_jsx(true),
61 Self::Jsx => oxc_span::SourceType::jsx(),
62 Self::Ts => oxc_span::SourceType::ts(),
63 Self::Tsx => oxc_span::SourceType::tsx(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct MutationOptions {
71 pub operators: Vec<Operator>,
73 pub ranges: Option<Vec<(u32, u32)>>,
76}
77
78impl Default for MutationOptions {
79 fn default() -> Self {
80 Self {
81 operators: Operator::ALL.to_vec(),
82 ranges: None,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
89#[serde(transparent)]
90pub struct MutantId(pub String);
91
92impl MutantId {
93 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97}
98
99impl std::fmt::Display for MutantId {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.write_str(&self.0)
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct FunctionRef {
108 pub name: Option<String>,
110 pub span: (u32, u32),
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct Mutant {
117 pub id: MutantId,
119 pub operator: Operator,
121 pub span: (u32, u32),
123 pub line: u32,
125 pub column: u32,
127 pub original: String,
129 pub replacement: String,
134 pub enclosing_function: Option<FunctionRef>,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
140pub struct SpliceSegment {
141 pub generated_start: u32,
143 pub original_start: u32,
145 pub len: u32,
147}
148
149#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
155pub struct SpliceMap {
156 pub segments: Vec<SpliceSegment>,
158}
159
160impl SpliceMap {
161 pub fn original_offset(&self, generated: u32) -> Option<u32> {
164 let index = self
165 .segments
166 .partition_point(|segment| segment.generated_start <= generated);
167 let segment = self.segments.get(index.checked_sub(1)?)?;
168 let delta = generated - segment.generated_start;
169 (delta < segment.len).then_some(segment.original_start + delta)
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Instrumented {
176 pub mutants: Vec<Mutant>,
178 pub code: String,
180 pub map: SpliceMap,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum MutationError {
187 Parse {
189 messages: Vec<String>,
191 },
192 TooLarge,
195 ReservedName {
198 name: String,
200 },
201}
202
203impl std::fmt::Display for MutationError {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 match self {
206 Self::Parse { messages } => {
207 write!(f, "the source does not parse: {}", messages.join("; "))
208 }
209 Self::TooLarge => {
210 f.write_str("the source or the instrumented output is larger than 4 GiB")
211 }
212 Self::ReservedName { name } => write!(
213 f,
214 "the source declares `{name}`, a name that the mutation prelude reserves"
215 ),
216 }
217 }
218}
219
220impl std::error::Error for MutationError {}
221
222const fn is_identifier_byte(byte: u8) -> bool {
225 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || byte >= 0x80
226}
227
228pub const RESERVED_PREFIX: &str = "__fallow_m_";
237
238pub const ACTIVE_FUNCTION: &str = "__fallow_m_a";
244
245pub const HIT_FUNCTION: &str = "__fallow_m_c";
251
252pub fn instrument(
257 source: &str,
258 path: &str,
259 source_type: SourceKind,
260 options: &MutationOptions,
261) -> Result<Instrumented, MutationError> {
262 let mut seen_ids = std::collections::BTreeSet::new();
263 let text = TextInput {
264 source,
265 path,
266 kind: source_type,
267 role: None,
268 prelude: true,
269 };
270 instrument_text(&text, options, &mut seen_ids)
271}
272
273pub(crate) struct TextInput<'s> {
275 pub(crate) source: &'s str,
276 pub(crate) path: &'s str,
277 pub(crate) kind: SourceKind,
278 pub(crate) role: Option<ScriptRole>,
280 pub(crate) prelude: bool,
282}
283
284pub(crate) fn instrument_text(
288 input: &TextInput<'_>,
289 options: &MutationOptions,
290 seen_ids: &mut std::collections::BTreeSet<MutantId>,
291) -> Result<Instrumented, MutationError> {
292 let source = input.source;
293 if u32::try_from(source.len()).is_err() {
294 return Err(MutationError::TooLarge);
295 }
296 let allocator = oxc_allocator::Allocator::default();
297 let parsed = oxc_parser::Parser::new(&allocator, source, input.kind.source_type()).parse();
298 if parsed.fatal_error || parsed.diagnostics.has_errors() {
299 let messages = parsed.diagnostics.iter().map(ToString::to_string).collect();
300 return Err(MutationError::Parse { messages });
301 }
302 if let Some(name) = reserved_binding(&parsed.program) {
303 return Err(MutationError::ReservedName { name });
304 }
305 let collected = collect::collect(&parsed.program, source);
306 let protected = input
307 .role
308 .map(|role| blocks::protected_spans(&parsed.program, role))
309 .unwrap_or_default();
310 let insertions = header_insertions(&parsed.program, source, input.prelude);
311 let line_starts = line_starts(source);
312
313 let mut mutants = Vec::new();
314 let mut emit_sites = Vec::new();
315 for site in collected.sites {
316 if protected.excludes(site.span) {
317 continue;
318 }
319 let kept = select_candidates(source, &site, &collected.statement_starts, options);
320 if kept.is_empty() {
321 continue;
322 }
323 let mut emit_mutants = Vec::with_capacity(kept.len());
324 for candidate in kept {
325 let id = unique_id(input.path, source, &site, &candidate, seen_ids);
326 let (line, column) = line_column(&line_starts, candidate.region.0);
327 emit_mutants.push(emit::EmitMutant {
328 id: id.0.clone(),
329 region: candidate.region,
330 replacement: candidate.replacement.clone(),
331 });
332 mutants.push(Mutant {
333 id,
334 operator: candidate.operator,
335 span: candidate.region,
336 line,
337 column,
338 original: slice(source, candidate.region).to_owned(),
339 replacement: candidate.replacement,
340 enclosing_function: site.function.clone(),
341 });
342 }
343 emit_sites.push(emit::EmitSite {
344 span: site.span,
345 mutants: emit_mutants,
346 });
347 }
348 mutants.sort_by_key(|mutant| mutant.span);
349 let (code, map) = emit::emit(
350 source,
351 &emit_sites,
352 &collected.statement_starts,
353 &insertions,
354 );
355 if u32::try_from(code.len()).is_err() {
358 return Err(MutationError::TooLarge);
359 }
360 Ok(Instrumented { mutants, code, map })
361}
362
363fn reserved_binding(program: &oxc_ast::ast::Program<'_>) -> Option<String> {
365 use oxc_ast_visit::Visit;
366
367 #[derive(Default)]
368 struct Finder {
369 found: Option<String>,
370 }
371 impl<'a> Visit<'a> for Finder {
372 fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) {
373 let name = it.name.as_str();
374 if self.found.is_none() && (name == ACTIVE_FUNCTION || name == HIT_FUNCTION) {
375 self.found = Some(name.to_owned());
376 }
377 }
378 }
379
380 let mut finder = Finder::default();
381 finder.visit_program(program);
382 finder.found
383}
384
385const 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); }";
395
396const TS_NOCHECK: &str = "// @ts-nocheck";
397
398fn header_insertions(
409 program: &oxc_ast::ast::Program<'_>,
410 source: &str,
411 prelude: bool,
412) -> Vec<emit::Insertion> {
413 let offset = leading_end(program, source);
414 let line_break_follows = source
415 .get(offset as usize..)
416 .is_some_and(|rest| rest.starts_with(['\n', '\r', '\u{2028}', '\u{2029}']));
417 let nocheck = if offset == 0 {
418 format!("{TS_NOCHECK}\n")
419 } else if program.directives.is_empty() || line_break_follows {
420 format!("\n{TS_NOCHECK}")
421 } else {
422 format!("\n{TS_NOCHECK}\n")
423 };
424 if !prelude {
425 return vec![emit::Insertion {
426 offset,
427 text: nocheck,
428 }];
429 }
430 let Some(last) = program.directives.last() else {
431 let text = if offset == 0 {
432 format!("{nocheck}{PRELUDE}\n")
433 } else {
434 format!("{nocheck}\n{PRELUDE}")
435 };
436 return vec![emit::Insertion { offset, text }];
437 };
438 let end = last.span.end;
439 let terminated = source
440 .get(..end as usize)
441 .is_some_and(|text| text.ends_with(';'));
442 let separator = if terminated { "" } else { ";" };
443 vec![
444 emit::Insertion {
445 offset,
446 text: nocheck,
447 },
448 emit::Insertion {
449 offset: end,
450 text: format!("{separator}{PRELUDE}"),
451 },
452 ]
453}
454
455fn leading_end(program: &oxc_ast::ast::Program<'_>, source: &str) -> u32 {
457 use oxc_span::GetSpan;
458 let first_token = program
459 .directives
460 .first()
461 .map(|directive| directive.span.start)
462 .or_else(|| program.body.first().map(|statement| statement.span().start))
463 .unwrap_or(source.len() as u32);
464 let hashbang_end = program
465 .hashbang
466 .as_ref()
467 .map_or(0, |hashbang| hashbang.span.end);
468 program
469 .comments
470 .iter()
471 .take_while(|comment| comment.span.end <= first_token)
472 .map(|comment| comment.span.end)
473 .last()
474 .map_or(hashbang_end, |end| end.max(hashbang_end))
475}
476
477fn slice(source: &str, span: (u32, u32)) -> &str {
478 source
479 .get(span.0 as usize..span.1 as usize)
480 .unwrap_or_default()
481}
482
483fn select_candidates(
486 source: &str,
487 site: &collect::Site,
488 statement_starts: &std::collections::BTreeSet<u32>,
489 options: &MutationOptions,
490) -> Vec<collect::Candidate> {
491 let original = slice(source, site.span);
492 let mut texts = std::collections::BTreeSet::new();
493 let mut kept = Vec::new();
494 for candidate in &site.candidates {
495 if !options.operators.contains(&candidate.operator) {
496 continue;
497 }
498 let (start, end) = candidate.region;
499 let in_range = options
500 .ranges
501 .as_ref()
502 .is_none_or(|ranges| ranges.iter().any(|&(from, to)| start < to && from < end));
503 if !in_range {
504 continue;
505 }
506 let replacement = guard_statement_start(source, statement_starts, candidate);
507 let mutated = format!(
508 "{}{}{}",
509 slice(source, (site.span.0, start)),
510 replacement,
511 slice(source, (end, site.span.1)),
512 );
513 if mutated == original || !texts.insert(mutated) {
514 continue;
515 }
516 kept.push(collect::Candidate {
517 operator: candidate.operator,
518 region: candidate.region,
519 replacement,
520 });
521 }
522 kept
523}
524
525const ASI_HAZARDS: [char; 6] = ['(', '[', '`', '+', '-', '/'];
528
529fn starts_with_asi_hazard(text: &str) -> bool {
533 let hazard = text.starts_with(ASI_HAZARDS);
534 hazard && !text.starts_with("++") && !text.starts_with("--")
535}
536
537fn guard_statement_start(
542 source: &str,
543 statement_starts: &std::collections::BTreeSet<u32>,
544 candidate: &collect::Candidate,
545) -> String {
546 let (start, end) = candidate.region;
547 let guarded = statement_starts.contains(&start)
548 && starts_with_asi_hazard(&candidate.replacement)
549 && !starts_with_asi_hazard(slice(source, (start, end)));
550 if !guarded {
551 return candidate.replacement.clone();
552 }
553 let joins = source
555 .as_bytes()
556 .get((start as usize).wrapping_sub(1))
557 .is_some_and(|&byte| is_identifier_byte(byte));
558 let prefix = if joins { " void " } else { "void " };
559 format!("{prefix}{}", candidate.replacement)
560}
561
562fn unique_id(
563 path: &str,
564 source: &str,
565 site: &collect::Site,
566 candidate: &collect::Candidate,
567 seen: &mut std::collections::BTreeSet<MutantId>,
568) -> MutantId {
569 let anchor_start = site.anchor.0;
570 let input = id::IdInput {
571 path,
572 anchor_text: slice(source, site.anchor),
573 relative: (
574 candidate.region.0.saturating_sub(anchor_start),
575 candidate.region.1.saturating_sub(anchor_start),
576 ),
577 operator: candidate.operator,
578 replacement: &candidate.replacement,
579 };
580 let mut occurrence = 0;
581 loop {
582 let id = id::mutant_id(&input, occurrence);
583 if seen.insert(id.clone()) {
584 return id;
585 }
586 occurrence += 1;
587 }
588}
589
590fn line_starts(source: &str) -> Vec<u32> {
591 std::iter::once(0)
592 .chain(
593 source
594 .match_indices('\n')
595 .map(|(index, _)| index as u32 + 1),
596 )
597 .collect()
598}
599
600fn line_column(line_starts: &[u32], offset: u32) -> (u32, u32) {
601 let index = line_starts
602 .partition_point(|&start| start <= offset)
603 .saturating_sub(1);
604 let start = line_starts.get(index).copied().unwrap_or(0);
605 (index as u32 + 1, offset - start)
606}