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 pub change: MutantChange,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
147pub struct MutantChange {
148 pub from: &'static str,
150 pub to: &'static str,
152}
153
154impl MutantChange {
155 pub const CONDITION: &'static str = "<condition>";
157 pub const CALL: &'static str = "<call>";
159 pub const EXPRESSION: &'static str = "<expression>";
162 pub const STRING: &'static str = "<string>";
164
165 pub const fn new(from: &'static str, to: &'static str) -> Self {
167 Self { from, to }
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173pub struct SpliceSegment {
174 pub generated_start: u32,
176 pub original_start: u32,
178 pub len: u32,
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
188pub struct SpliceMap {
189 pub segments: Vec<SpliceSegment>,
191}
192
193impl SpliceMap {
194 pub fn original_offset(&self, generated: u32) -> Option<u32> {
197 let index = self
198 .segments
199 .partition_point(|segment| segment.generated_start <= generated);
200 let segment = self.segments.get(index.checked_sub(1)?)?;
201 let delta = generated - segment.generated_start;
202 (delta < segment.len).then_some(segment.original_start + delta)
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct Instrumented {
209 pub mutants: Vec<Mutant>,
211 pub code: String,
213 pub map: SpliceMap,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219pub enum MutationError {
220 Parse {
222 messages: Vec<String>,
224 },
225 TooLarge,
228 ReservedName {
231 name: String,
233 },
234}
235
236impl std::fmt::Display for MutationError {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 match self {
239 Self::Parse { messages } => {
240 write!(f, "the source does not parse: {}", messages.join("; "))
241 }
242 Self::TooLarge => {
243 f.write_str("the source or the instrumented output is larger than 4 GiB")
244 }
245 Self::ReservedName { name } => write!(
246 f,
247 "the source declares `{name}`, a name that the mutation prelude reserves"
248 ),
249 }
250 }
251}
252
253impl std::error::Error for MutationError {}
254
255const fn is_identifier_byte(byte: u8) -> bool {
258 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || byte >= 0x80
259}
260
261pub const RESERVED_PREFIX: &str = "__fallow_m_";
270
271pub const ACTIVE_FUNCTION: &str = "__fallow_m_a";
277
278pub const HIT_FUNCTION: &str = "__fallow_m_c";
284
285pub fn instrument(
290 source: &str,
291 path: &str,
292 source_type: SourceKind,
293 options: &MutationOptions,
294) -> Result<Instrumented, MutationError> {
295 let mut seen_ids = std::collections::BTreeSet::new();
296 let text = TextInput {
297 source,
298 path,
299 kind: source_type,
300 role: None,
301 prelude: true,
302 };
303 instrument_text(&text, options, &mut seen_ids)
304}
305
306pub(crate) struct TextInput<'s> {
308 pub(crate) source: &'s str,
309 pub(crate) path: &'s str,
310 pub(crate) kind: SourceKind,
311 pub(crate) role: Option<ScriptRole>,
313 pub(crate) prelude: bool,
315}
316
317pub(crate) fn instrument_text(
321 input: &TextInput<'_>,
322 options: &MutationOptions,
323 seen_ids: &mut std::collections::BTreeSet<MutantId>,
324) -> Result<Instrumented, MutationError> {
325 let source = input.source;
326 if u32::try_from(source.len()).is_err() {
327 return Err(MutationError::TooLarge);
328 }
329 let allocator = oxc_allocator::Allocator::default();
330 let parsed = oxc_parser::Parser::new(&allocator, source, input.kind.source_type()).parse();
331 if parsed.fatal_error || parsed.diagnostics.has_errors() {
332 let messages = parsed.diagnostics.iter().map(ToString::to_string).collect();
333 return Err(MutationError::Parse { messages });
334 }
335 if let Some(name) = reserved_binding(&parsed.program) {
336 return Err(MutationError::ReservedName { name });
337 }
338 let collected = collect::collect(&parsed.program, source);
339 let protected = input
340 .role
341 .map(|role| blocks::protected_spans(&parsed.program, role))
342 .unwrap_or_default();
343 let insertions = header_insertions(&parsed.program, source, input.prelude);
344 let line_starts = line_starts(source);
345
346 let mut mutants = Vec::new();
347 let mut emit_sites = Vec::new();
348 for site in collected.sites {
349 if protected.excludes(site.span) {
350 continue;
351 }
352 let kept = select_candidates(source, &site, &collected.statement_starts, options);
353 if kept.is_empty() {
354 continue;
355 }
356 let mut emit_mutants = Vec::with_capacity(kept.len());
357 for candidate in kept {
358 let id = unique_id(input.path, source, &site, &candidate, seen_ids);
359 let (line, column) = line_column(&line_starts, candidate.region.0);
360 emit_mutants.push(emit::EmitMutant {
361 id: id.0.clone(),
362 region: candidate.region,
363 replacement: candidate.replacement.clone(),
364 });
365 mutants.push(Mutant {
366 id,
367 operator: candidate.operator,
368 span: candidate.region,
369 line,
370 column,
371 original: slice(source, candidate.region).to_owned(),
372 replacement: candidate.replacement,
373 enclosing_function: site.function.clone(),
374 change: candidate.change,
375 });
376 }
377 emit_sites.push(emit::EmitSite {
378 span: site.span,
379 mutants: emit_mutants,
380 });
381 }
382 mutants.sort_by_key(|mutant| mutant.span);
383 let (code, map) = emit::emit(
384 source,
385 &emit_sites,
386 &collected.statement_starts,
387 &insertions,
388 );
389 if u32::try_from(code.len()).is_err() {
392 return Err(MutationError::TooLarge);
393 }
394 Ok(Instrumented { mutants, code, map })
395}
396
397fn reserved_binding(program: &oxc_ast::ast::Program<'_>) -> Option<String> {
399 use oxc_ast_visit::Visit;
400
401 #[derive(Default)]
402 struct Finder {
403 found: Option<String>,
404 }
405 impl<'a> Visit<'a> for Finder {
406 fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) {
407 let name = it.name.as_str();
408 if self.found.is_none() && (name == ACTIVE_FUNCTION || name == HIT_FUNCTION) {
409 self.found = Some(name.to_owned());
410 }
411 }
412 }
413
414 let mut finder = Finder::default();
415 finder.visit_program(program);
416 finder.found
417}
418
419const 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); }";
429
430const TS_NOCHECK: &str = "// @ts-nocheck";
431
432fn header_insertions(
443 program: &oxc_ast::ast::Program<'_>,
444 source: &str,
445 prelude: bool,
446) -> Vec<emit::Insertion> {
447 let offset = leading_end(program, source);
448 let line_break_follows = source
449 .get(offset as usize..)
450 .is_some_and(|rest| rest.starts_with(['\n', '\r', '\u{2028}', '\u{2029}']));
451 let nocheck = if offset == 0 {
452 format!("{TS_NOCHECK}\n")
453 } else if program.directives.is_empty() || line_break_follows {
454 format!("\n{TS_NOCHECK}")
455 } else {
456 format!("\n{TS_NOCHECK}\n")
457 };
458 if !prelude {
459 return vec![emit::Insertion {
460 offset,
461 text: nocheck,
462 }];
463 }
464 let Some(last) = program.directives.last() else {
465 let text = if offset == 0 {
466 format!("{nocheck}{PRELUDE}\n")
467 } else {
468 format!("{nocheck}\n{PRELUDE}")
469 };
470 return vec![emit::Insertion { offset, text }];
471 };
472 let end = last.span.end;
473 let terminated = source
474 .get(..end as usize)
475 .is_some_and(|text| text.ends_with(';'));
476 let separator = if terminated { "" } else { ";" };
477 vec![
478 emit::Insertion {
479 offset,
480 text: nocheck,
481 },
482 emit::Insertion {
483 offset: end,
484 text: format!("{separator}{PRELUDE}"),
485 },
486 ]
487}
488
489fn leading_end(program: &oxc_ast::ast::Program<'_>, source: &str) -> u32 {
491 use oxc_span::GetSpan;
492 let first_token = program
493 .directives
494 .first()
495 .map(|directive| directive.span.start)
496 .or_else(|| program.body.first().map(|statement| statement.span().start))
497 .unwrap_or(source.len() as u32);
498 let hashbang_end = program
499 .hashbang
500 .as_ref()
501 .map_or(0, |hashbang| hashbang.span.end);
502 program
503 .comments
504 .iter()
505 .take_while(|comment| comment.span.end <= first_token)
506 .map(|comment| comment.span.end)
507 .last()
508 .map_or(hashbang_end, |end| end.max(hashbang_end))
509}
510
511fn slice(source: &str, span: (u32, u32)) -> &str {
512 source
513 .get(span.0 as usize..span.1 as usize)
514 .unwrap_or_default()
515}
516
517fn select_candidates(
520 source: &str,
521 site: &collect::Site,
522 statement_starts: &std::collections::BTreeSet<u32>,
523 options: &MutationOptions,
524) -> Vec<collect::Candidate> {
525 let original = slice(source, site.span);
526 let mut texts = std::collections::BTreeSet::new();
527 let mut kept = Vec::new();
528 for candidate in &site.candidates {
529 if !options.operators.contains(&candidate.operator) {
530 continue;
531 }
532 let (start, end) = candidate.region;
533 let in_range = options
534 .ranges
535 .as_ref()
536 .is_none_or(|ranges| ranges.iter().any(|&(from, to)| start < to && from < end));
537 if !in_range {
538 continue;
539 }
540 let replacement = guard_statement_start(source, statement_starts, candidate);
541 let mutated = format!(
542 "{}{}{}",
543 slice(source, (site.span.0, start)),
544 replacement,
545 slice(source, (end, site.span.1)),
546 );
547 if mutated == original || !texts.insert(mutated) {
548 continue;
549 }
550 kept.push(collect::Candidate {
551 operator: candidate.operator,
552 region: candidate.region,
553 replacement,
554 change: candidate.change,
555 });
556 }
557 kept
558}
559
560const ASI_HAZARDS: [char; 6] = ['(', '[', '`', '+', '-', '/'];
563
564fn starts_with_asi_hazard(text: &str) -> bool {
568 let hazard = text.starts_with(ASI_HAZARDS);
569 hazard && !text.starts_with("++") && !text.starts_with("--")
570}
571
572fn guard_statement_start(
577 source: &str,
578 statement_starts: &std::collections::BTreeSet<u32>,
579 candidate: &collect::Candidate,
580) -> String {
581 let (start, end) = candidate.region;
582 let guarded = statement_starts.contains(&start)
583 && starts_with_asi_hazard(&candidate.replacement)
584 && !starts_with_asi_hazard(slice(source, (start, end)));
585 if !guarded {
586 return candidate.replacement.clone();
587 }
588 let joins = source
590 .as_bytes()
591 .get((start as usize).wrapping_sub(1))
592 .is_some_and(|&byte| is_identifier_byte(byte));
593 let prefix = if joins { " void " } else { "void " };
594 format!("{prefix}{}", candidate.replacement)
595}
596
597fn unique_id(
598 path: &str,
599 source: &str,
600 site: &collect::Site,
601 candidate: &collect::Candidate,
602 seen: &mut std::collections::BTreeSet<MutantId>,
603) -> MutantId {
604 let anchor_start = site.anchor.0;
605 let input = id::IdInput {
606 path,
607 anchor_text: slice(source, site.anchor),
608 relative: (
609 candidate.region.0.saturating_sub(anchor_start),
610 candidate.region.1.saturating_sub(anchor_start),
611 ),
612 operator: candidate.operator,
613 replacement: &candidate.replacement,
614 };
615 let mut occurrence = 0;
616 loop {
617 let id = id::mutant_id(&input, occurrence);
618 if seen.insert(id.clone()) {
619 return id;
620 }
621 occurrence += 1;
622 }
623}
624
625fn line_starts(source: &str) -> Vec<u32> {
626 std::iter::once(0)
627 .chain(
628 source
629 .match_indices('\n')
630 .map(|(index, _)| index as u32 + 1),
631 )
632 .collect()
633}
634
635fn line_column(line_starts: &[u32], offset: u32) -> (u32, u32) {
636 let index = line_starts
637 .partition_point(|&start| start <= offset)
638 .saturating_sub(1);
639 let start = line_starts.get(index).copied().unwrap_or(0);
640 (index as u32 + 1, offset - start)
641}