1mod blocks;
18mod collect;
19mod emit;
20mod id;
21mod logging_only;
22mod operator;
23mod regex;
24
25use serde::Serialize;
26
27pub use blocks::{ScriptBlock, ScriptRole, instrument_blocks};
28pub use operator::Operator;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum SourceKind {
34 Js,
37 Jsx,
39 Ts,
41 Tsx,
43}
44
45impl SourceKind {
46 pub fn from_path(path: &str) -> Option<Self> {
48 let extension = path.rsplit_once('.')?.1;
49 match extension {
50 "js" | "mjs" | "cjs" => Some(Self::Js),
51 "jsx" => Some(Self::Jsx),
52 "ts" | "mts" | "cts" => Some(Self::Ts),
53 "tsx" => Some(Self::Tsx),
54 _ => None,
55 }
56 }
57
58 pub const fn source_type(self) -> oxc_span::SourceType {
60 match self {
61 Self::Js => oxc_span::SourceType::unambiguous().with_jsx(true),
62 Self::Jsx => oxc_span::SourceType::jsx(),
63 Self::Ts => oxc_span::SourceType::ts(),
64 Self::Tsx => oxc_span::SourceType::tsx(),
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct MutationOptions {
72 pub operators: Vec<Operator>,
74 pub ranges: Option<Vec<(u32, u32)>>,
77}
78
79impl Default for MutationOptions {
80 fn default() -> Self {
81 Self {
82 operators: Operator::ALL.to_vec(),
83 ranges: None,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
90#[serde(transparent)]
91pub struct MutantId(pub String);
92
93impl MutantId {
94 pub fn as_str(&self) -> &str {
96 &self.0
97 }
98}
99
100impl std::fmt::Display for MutantId {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.write_str(&self.0)
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108pub struct FunctionRef {
109 pub name: Option<String>,
111 pub span: (u32, u32),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117pub struct Mutant {
118 pub id: MutantId,
120 pub operator: Operator,
122 pub span: (u32, u32),
124 pub line: u32,
126 pub column: u32,
128 pub original: String,
130 pub replacement: String,
135 pub enclosing_function: Option<FunctionRef>,
137 pub change: MutantChange,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
148pub struct MutantChange {
149 pub from: &'static str,
151 pub to: &'static str,
153}
154
155impl MutantChange {
156 pub const CONDITION: &'static str = "<condition>";
158 pub const CALL: &'static str = "<call>";
160 pub const EXPRESSION: &'static str = "<expression>";
163 pub const STRING: &'static str = "<string>";
165
166 pub const fn new(from: &'static str, to: &'static str) -> Self {
168 Self { from, to }
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
174pub struct SpliceSegment {
175 pub generated_start: u32,
177 pub original_start: u32,
179 pub len: u32,
181}
182
183#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
189pub struct SpliceMap {
190 pub segments: Vec<SpliceSegment>,
192}
193
194impl SpliceMap {
195 pub fn original_offset(&self, generated: u32) -> Option<u32> {
198 let index = self
199 .segments
200 .partition_point(|segment| segment.generated_start <= generated);
201 let segment = self.segments.get(index.checked_sub(1)?)?;
202 let delta = generated - segment.generated_start;
203 (delta < segment.len).then_some(segment.original_start + delta)
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct Instrumented {
210 pub mutants: Vec<Mutant>,
212 pub code: String,
214 pub map: SpliceMap,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum MutationError {
221 Parse {
223 messages: Vec<String>,
225 },
226 TooLarge,
229 ReservedName {
232 name: String,
234 },
235}
236
237impl std::fmt::Display for MutationError {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 match self {
240 Self::Parse { messages } => {
241 write!(f, "the source does not parse: {}", messages.join("; "))
242 }
243 Self::TooLarge => {
244 f.write_str("the source or the instrumented output is larger than 4 GiB")
245 }
246 Self::ReservedName { name } => write!(
247 f,
248 "the source declares `{name}`, a name that the mutation prelude reserves"
249 ),
250 }
251 }
252}
253
254impl std::error::Error for MutationError {}
255
256const fn is_identifier_byte(byte: u8) -> bool {
259 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || byte >= 0x80
260}
261
262pub const RESERVED_PREFIX: &str = "__fallow_m_";
271
272pub const ACTIVE_FUNCTION: &str = "__fallow_m_a";
278
279pub const HIT_FUNCTION: &str = "__fallow_m_c";
285
286pub fn instrument(
291 source: &str,
292 path: &str,
293 source_type: SourceKind,
294 options: &MutationOptions,
295) -> Result<Instrumented, MutationError> {
296 let mut seen_ids = std::collections::BTreeSet::new();
297 let text = TextInput {
298 source,
299 path,
300 kind: source_type,
301 role: None,
302 prelude: true,
303 };
304 instrument_text(&text, options, &mut seen_ids)
305}
306
307pub(crate) struct TextInput<'s> {
309 pub(crate) source: &'s str,
310 pub(crate) path: &'s str,
311 pub(crate) kind: SourceKind,
312 pub(crate) role: Option<ScriptRole>,
314 pub(crate) prelude: bool,
316}
317
318pub(crate) fn instrument_text(
322 input: &TextInput<'_>,
323 options: &MutationOptions,
324 seen_ids: &mut std::collections::BTreeSet<MutantId>,
325) -> Result<Instrumented, MutationError> {
326 let source = input.source;
327 if u32::try_from(source.len()).is_err() {
328 return Err(MutationError::TooLarge);
329 }
330 let allocator = oxc_allocator::Allocator::default();
331 let parsed = oxc_parser::Parser::new(&allocator, source, input.kind.source_type()).parse();
332 if parsed.fatal_error || parsed.diagnostics.has_errors() {
333 let messages = parsed.diagnostics.iter().map(ToString::to_string).collect();
334 return Err(MutationError::Parse { messages });
335 }
336 if let Some(name) = reserved_binding(&parsed.program) {
337 return Err(MutationError::ReservedName { name });
338 }
339 let collected = collect::collect(&parsed.program, source);
340 let protected = input
341 .role
342 .map(|role| blocks::protected_spans(&parsed.program, role))
343 .unwrap_or_default();
344 let insertions = header_insertions(&parsed.program, source, input.prelude);
345 let line_starts = line_starts(source);
346
347 let mut mutants = Vec::new();
348 let mut emit_sites = Vec::new();
349 for site in collected.sites {
350 if protected.excludes(site.span) {
351 continue;
352 }
353 let kept = select_candidates(source, &site, &collected.statement_starts, options);
354 if kept.is_empty() {
355 continue;
356 }
357 let mut emit_mutants = Vec::with_capacity(kept.len());
358 for candidate in kept {
359 let id = unique_id(input.path, source, &site, &candidate, seen_ids);
360 let (line, column) = line_column(&line_starts, candidate.region.0);
361 emit_mutants.push(emit::EmitMutant {
362 id: id.0.clone(),
363 region: candidate.region,
364 replacement: candidate.replacement.clone(),
365 });
366 mutants.push(Mutant {
367 id,
368 operator: candidate.operator,
369 span: candidate.region,
370 line,
371 column,
372 original: slice(source, candidate.region).to_owned(),
373 replacement: candidate.replacement,
374 enclosing_function: site.function.clone(),
375 change: candidate.change,
376 });
377 }
378 emit_sites.push(emit::EmitSite {
379 span: site.span,
380 mutants: emit_mutants,
381 });
382 }
383 mutants.sort_by_key(|mutant| mutant.span);
384 let (code, map) = emit::emit(
385 source,
386 &emit_sites,
387 &collected.statement_starts,
388 &insertions,
389 );
390 if u32::try_from(code.len()).is_err() {
393 return Err(MutationError::TooLarge);
394 }
395 Ok(Instrumented { mutants, code, map })
396}
397
398fn reserved_binding(program: &oxc_ast::ast::Program<'_>) -> Option<String> {
400 use oxc_ast_visit::Visit;
401
402 #[derive(Default)]
403 struct Finder {
404 found: Option<String>,
405 }
406 impl<'a> Visit<'a> for Finder {
407 fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) {
408 let name = it.name.as_str();
409 if self.found.is_none() && (name == ACTIVE_FUNCTION || name == HIT_FUNCTION) {
410 self.found = Some(name.to_owned());
411 }
412 }
413 }
414
415 let mut finder = Finder::default();
416 finder.visit_program(program);
417 finder.found
418}
419
420const 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); }";
430
431const TS_NOCHECK: &str = "// @ts-nocheck";
432
433fn header_insertions(
444 program: &oxc_ast::ast::Program<'_>,
445 source: &str,
446 prelude: bool,
447) -> Vec<emit::Insertion> {
448 let offset = leading_end(program, source);
449 let line_break_follows = source
450 .get(offset as usize..)
451 .is_some_and(|rest| rest.starts_with(['\n', '\r', '\u{2028}', '\u{2029}']));
452 let nocheck = if offset == 0 {
453 format!("{TS_NOCHECK}\n")
454 } else if program.directives.is_empty() || line_break_follows {
455 format!("\n{TS_NOCHECK}")
456 } else {
457 format!("\n{TS_NOCHECK}\n")
458 };
459 if !prelude {
460 return vec![emit::Insertion {
461 offset,
462 text: nocheck,
463 }];
464 }
465 let Some(last) = program.directives.last() else {
466 let text = if offset == 0 {
467 format!("{nocheck}{PRELUDE}\n")
468 } else {
469 format!("{nocheck}\n{PRELUDE}")
470 };
471 return vec![emit::Insertion { offset, text }];
472 };
473 let end = last.span.end;
474 let terminated = source
475 .get(..end as usize)
476 .is_some_and(|text| text.ends_with(';'));
477 let separator = if terminated { "" } else { ";" };
478 vec![
479 emit::Insertion {
480 offset,
481 text: nocheck,
482 },
483 emit::Insertion {
484 offset: end,
485 text: format!("{separator}{PRELUDE}"),
486 },
487 ]
488}
489
490fn leading_end(program: &oxc_ast::ast::Program<'_>, source: &str) -> u32 {
492 use oxc_span::GetSpan;
493 let first_token = program
494 .directives
495 .first()
496 .map(|directive| directive.span.start)
497 .or_else(|| program.body.first().map(|statement| statement.span().start))
498 .unwrap_or(source.len() as u32);
499 let hashbang_end = program
500 .hashbang
501 .as_ref()
502 .map_or(0, |hashbang| hashbang.span.end);
503 program
504 .comments
505 .iter()
506 .take_while(|comment| comment.span.end <= first_token)
507 .map(|comment| comment.span.end)
508 .last()
509 .map_or(hashbang_end, |end| end.max(hashbang_end))
510}
511
512fn slice(source: &str, span: (u32, u32)) -> &str {
513 source
514 .get(span.0 as usize..span.1 as usize)
515 .unwrap_or_default()
516}
517
518fn select_candidates(
521 source: &str,
522 site: &collect::Site,
523 statement_starts: &std::collections::BTreeSet<u32>,
524 options: &MutationOptions,
525) -> Vec<collect::Candidate> {
526 let original = slice(source, site.span);
527 let mut texts = std::collections::BTreeSet::new();
528 let mut kept = Vec::new();
529 for candidate in &site.candidates {
530 if !options.operators.contains(&candidate.operator) {
531 continue;
532 }
533 let (start, end) = candidate.region;
534 let in_range = options
535 .ranges
536 .as_ref()
537 .is_none_or(|ranges| ranges.iter().any(|&(from, to)| start < to && from < end));
538 if !in_range {
539 continue;
540 }
541 let replacement = guard_statement_start(source, statement_starts, candidate);
542 let mutated = format!(
543 "{}{}{}",
544 slice(source, (site.span.0, start)),
545 replacement,
546 slice(source, (end, site.span.1)),
547 );
548 if mutated == original || !texts.insert(mutated) {
549 continue;
550 }
551 kept.push(collect::Candidate {
552 operator: candidate.operator,
553 region: candidate.region,
554 replacement,
555 change: candidate.change,
556 });
557 }
558 kept
559}
560
561const ASI_HAZARDS: [char; 6] = ['(', '[', '`', '+', '-', '/'];
564
565fn starts_with_asi_hazard(text: &str) -> bool {
569 let hazard = text.starts_with(ASI_HAZARDS);
570 hazard && !text.starts_with("++") && !text.starts_with("--")
571}
572
573fn guard_statement_start(
578 source: &str,
579 statement_starts: &std::collections::BTreeSet<u32>,
580 candidate: &collect::Candidate,
581) -> String {
582 let (start, end) = candidate.region;
583 let guarded = statement_starts.contains(&start)
584 && starts_with_asi_hazard(&candidate.replacement)
585 && !starts_with_asi_hazard(slice(source, (start, end)));
586 if !guarded {
587 return candidate.replacement.clone();
588 }
589 let joins = source
591 .as_bytes()
592 .get((start as usize).wrapping_sub(1))
593 .is_some_and(|&byte| is_identifier_byte(byte));
594 let prefix = if joins { " void " } else { "void " };
595 format!("{prefix}{}", candidate.replacement)
596}
597
598fn unique_id(
599 path: &str,
600 source: &str,
601 site: &collect::Site,
602 candidate: &collect::Candidate,
603 seen: &mut std::collections::BTreeSet<MutantId>,
604) -> MutantId {
605 let anchor_start = site.anchor.0;
606 let input = id::IdInput {
607 path,
608 anchor_text: slice(source, site.anchor),
609 relative: (
610 candidate.region.0.saturating_sub(anchor_start),
611 candidate.region.1.saturating_sub(anchor_start),
612 ),
613 operator: candidate.operator,
614 replacement: &candidate.replacement,
615 };
616 let mut occurrence = 0;
617 loop {
618 let id = id::mutant_id(&input, occurrence);
619 if seen.insert(id.clone()) {
620 return id;
621 }
622 occurrence += 1;
623 }
624}
625
626fn line_starts(source: &str) -> Vec<u32> {
627 std::iter::once(0)
628 .chain(
629 source
630 .match_indices('\n')
631 .map(|(index, _)| index as u32 + 1),
632 )
633 .collect()
634}
635
636fn line_column(line_starts: &[u32], offset: u32) -> (u32, u32) {
637 let index = line_starts
638 .partition_point(|&start| start <= offset)
639 .saturating_sub(1);
640 let start = line_starts.get(index).copied().unwrap_or(0);
641 (index as u32 + 1, offset - start)
642}