1mod collect;
18mod emit;
19mod id;
20mod operator;
21
22use serde::Serialize;
23
24pub use operator::Operator;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "kebab-case")]
29pub enum SourceKind {
30 Js,
33 Jsx,
35 Ts,
37 Tsx,
39}
40
41impl SourceKind {
42 pub fn from_path(path: &str) -> Option<Self> {
44 let extension = path.rsplit_once('.')?.1;
45 match extension {
46 "js" | "mjs" | "cjs" => Some(Self::Js),
47 "jsx" => Some(Self::Jsx),
48 "ts" | "mts" | "cts" => Some(Self::Ts),
49 "tsx" => Some(Self::Tsx),
50 _ => None,
51 }
52 }
53
54 pub const fn source_type(self) -> oxc_span::SourceType {
56 match self {
57 Self::Js => oxc_span::SourceType::unambiguous().with_jsx(true),
58 Self::Jsx => oxc_span::SourceType::jsx(),
59 Self::Ts => oxc_span::SourceType::ts(),
60 Self::Tsx => oxc_span::SourceType::tsx(),
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct MutationOptions {
68 pub operators: Vec<Operator>,
70 pub ranges: Option<Vec<(u32, u32)>>,
73}
74
75impl Default for MutationOptions {
76 fn default() -> Self {
77 Self {
78 operators: Operator::ALL.to_vec(),
79 ranges: None,
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
86#[serde(transparent)]
87pub struct MutantId(pub String);
88
89impl MutantId {
90 pub fn as_str(&self) -> &str {
92 &self.0
93 }
94}
95
96impl std::fmt::Display for MutantId {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.write_str(&self.0)
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104pub struct FunctionRef {
105 pub name: Option<String>,
107 pub span: (u32, u32),
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct Mutant {
114 pub id: MutantId,
116 pub operator: Operator,
118 pub span: (u32, u32),
120 pub line: u32,
122 pub column: u32,
124 pub original: String,
126 pub replacement: String,
131 pub enclosing_function: Option<FunctionRef>,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
137pub struct SpliceSegment {
138 pub generated_start: u32,
140 pub original_start: u32,
142 pub len: u32,
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
152pub struct SpliceMap {
153 pub segments: Vec<SpliceSegment>,
155}
156
157impl SpliceMap {
158 pub fn original_offset(&self, generated: u32) -> Option<u32> {
161 let index = self
162 .segments
163 .partition_point(|segment| segment.generated_start <= generated);
164 let segment = self.segments.get(index.checked_sub(1)?)?;
165 let delta = generated - segment.generated_start;
166 (delta < segment.len).then_some(segment.original_start + delta)
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct Instrumented {
173 pub mutants: Vec<Mutant>,
175 pub code: String,
177 pub map: SpliceMap,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum MutationError {
184 Parse {
186 messages: Vec<String>,
188 },
189 TooLarge,
192 ReservedName {
195 name: String,
197 },
198}
199
200impl std::fmt::Display for MutationError {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 Self::Parse { messages } => {
204 write!(f, "the source does not parse: {}", messages.join("; "))
205 }
206 Self::TooLarge => {
207 f.write_str("the source or the instrumented output is larger than 4 GiB")
208 }
209 Self::ReservedName { name } => write!(
210 f,
211 "the source declares `{name}`, a name that the mutation prelude reserves"
212 ),
213 }
214 }
215}
216
217impl std::error::Error for MutationError {}
218
219const fn is_identifier_byte(byte: u8) -> bool {
222 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || byte >= 0x80
223}
224
225pub const RESERVED_PREFIX: &str = "__fallow_m_";
234
235pub const ACTIVE_FUNCTION: &str = "__fallow_m_a";
241
242pub const HIT_FUNCTION: &str = "__fallow_m_c";
248
249pub fn instrument(
254 source: &str,
255 path: &str,
256 source_type: SourceKind,
257 options: &MutationOptions,
258) -> Result<Instrumented, MutationError> {
259 if u32::try_from(source.len()).is_err() {
260 return Err(MutationError::TooLarge);
261 }
262 let allocator = oxc_allocator::Allocator::default();
263 let parsed = oxc_parser::Parser::new(&allocator, source, source_type.source_type()).parse();
264 if parsed.fatal_error || parsed.diagnostics.has_errors() {
265 let messages = parsed.diagnostics.iter().map(ToString::to_string).collect();
266 return Err(MutationError::Parse { messages });
267 }
268 if let Some(name) = reserved_binding(&parsed.program) {
269 return Err(MutationError::ReservedName { name });
270 }
271 let collected = collect::collect(&parsed.program, source);
272 let insertions = header_insertions(&parsed.program, source);
273 let line_starts = line_starts(source);
274
275 let mut mutants = Vec::new();
276 let mut emit_sites = Vec::new();
277 let mut seen_ids = std::collections::BTreeSet::new();
278 for site in collected.sites {
279 let kept = select_candidates(source, &site, &collected.statement_starts, options);
280 if kept.is_empty() {
281 continue;
282 }
283 let mut emit_mutants = Vec::with_capacity(kept.len());
284 for candidate in kept {
285 let id = unique_id(path, source, &site, &candidate, &mut seen_ids);
286 let (line, column) = line_column(&line_starts, candidate.region.0);
287 emit_mutants.push(emit::EmitMutant {
288 id: id.0.clone(),
289 region: candidate.region,
290 replacement: candidate.replacement.clone(),
291 });
292 mutants.push(Mutant {
293 id,
294 operator: candidate.operator,
295 span: candidate.region,
296 line,
297 column,
298 original: slice(source, candidate.region).to_owned(),
299 replacement: candidate.replacement,
300 enclosing_function: site.function.clone(),
301 });
302 }
303 emit_sites.push(emit::EmitSite {
304 span: site.span,
305 mutants: emit_mutants,
306 });
307 }
308 mutants.sort_by_key(|mutant| mutant.span);
309 let (code, map) = emit::emit(
310 source,
311 &emit_sites,
312 &collected.statement_starts,
313 &insertions,
314 );
315 if u32::try_from(code.len()).is_err() {
318 return Err(MutationError::TooLarge);
319 }
320 Ok(Instrumented { mutants, code, map })
321}
322
323fn reserved_binding(program: &oxc_ast::ast::Program<'_>) -> Option<String> {
325 use oxc_ast_visit::Visit;
326
327 #[derive(Default)]
328 struct Finder {
329 found: Option<String>,
330 }
331 impl<'a> Visit<'a> for Finder {
332 fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) {
333 let name = it.name.as_str();
334 if self.found.is_none() && (name == ACTIVE_FUNCTION || name == HIT_FUNCTION) {
335 self.found = Some(name.to_owned());
336 }
337 }
338 }
339
340 let mut finder = Finder::default();
341 finder.visit_program(program);
342 finder.found
343}
344
345const 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); }";
355
356const TS_NOCHECK: &str = "// @ts-nocheck";
357
358fn header_insertions(program: &oxc_ast::ast::Program<'_>, source: &str) -> Vec<emit::Insertion> {
367 let offset = leading_end(program, source);
368 let line_break_follows = source
369 .get(offset as usize..)
370 .is_some_and(|rest| rest.starts_with(['\n', '\r', '\u{2028}', '\u{2029}']));
371 let nocheck = if offset == 0 {
372 format!("{TS_NOCHECK}\n")
373 } else if program.directives.is_empty() || line_break_follows {
374 format!("\n{TS_NOCHECK}")
375 } else {
376 format!("\n{TS_NOCHECK}\n")
377 };
378 let Some(last) = program.directives.last() else {
379 let text = if offset == 0 {
380 format!("{nocheck}{PRELUDE}\n")
381 } else {
382 format!("{nocheck}\n{PRELUDE}")
383 };
384 return vec![emit::Insertion { offset, text }];
385 };
386 let end = last.span.end;
387 let terminated = source
388 .get(..end as usize)
389 .is_some_and(|text| text.ends_with(';'));
390 let separator = if terminated { "" } else { ";" };
391 vec![
392 emit::Insertion {
393 offset,
394 text: nocheck,
395 },
396 emit::Insertion {
397 offset: end,
398 text: format!("{separator}{PRELUDE}"),
399 },
400 ]
401}
402
403fn leading_end(program: &oxc_ast::ast::Program<'_>, source: &str) -> u32 {
405 use oxc_span::GetSpan;
406 let first_token = program
407 .directives
408 .first()
409 .map(|directive| directive.span.start)
410 .or_else(|| program.body.first().map(|statement| statement.span().start))
411 .unwrap_or(source.len() as u32);
412 let hashbang_end = program
413 .hashbang
414 .as_ref()
415 .map_or(0, |hashbang| hashbang.span.end);
416 program
417 .comments
418 .iter()
419 .take_while(|comment| comment.span.end <= first_token)
420 .map(|comment| comment.span.end)
421 .last()
422 .map_or(hashbang_end, |end| end.max(hashbang_end))
423}
424
425fn slice(source: &str, span: (u32, u32)) -> &str {
426 source
427 .get(span.0 as usize..span.1 as usize)
428 .unwrap_or_default()
429}
430
431fn select_candidates(
434 source: &str,
435 site: &collect::Site,
436 statement_starts: &std::collections::BTreeSet<u32>,
437 options: &MutationOptions,
438) -> Vec<collect::Candidate> {
439 let original = slice(source, site.span);
440 let mut texts = std::collections::BTreeSet::new();
441 let mut kept = Vec::new();
442 for candidate in &site.candidates {
443 if !options.operators.contains(&candidate.operator) {
444 continue;
445 }
446 let (start, end) = candidate.region;
447 let in_range = options
448 .ranges
449 .as_ref()
450 .is_none_or(|ranges| ranges.iter().any(|&(from, to)| start < to && from < end));
451 if !in_range {
452 continue;
453 }
454 let replacement = guard_statement_start(source, statement_starts, candidate);
455 let mutated = format!(
456 "{}{}{}",
457 slice(source, (site.span.0, start)),
458 replacement,
459 slice(source, (end, site.span.1)),
460 );
461 if mutated == original || !texts.insert(mutated) {
462 continue;
463 }
464 kept.push(collect::Candidate {
465 operator: candidate.operator,
466 region: candidate.region,
467 replacement,
468 });
469 }
470 kept
471}
472
473const ASI_HAZARDS: [char; 6] = ['(', '[', '`', '+', '-', '/'];
476
477fn starts_with_asi_hazard(text: &str) -> bool {
481 let hazard = text.starts_with(ASI_HAZARDS);
482 hazard && !text.starts_with("++") && !text.starts_with("--")
483}
484
485fn guard_statement_start(
490 source: &str,
491 statement_starts: &std::collections::BTreeSet<u32>,
492 candidate: &collect::Candidate,
493) -> String {
494 let (start, end) = candidate.region;
495 let guarded = statement_starts.contains(&start)
496 && starts_with_asi_hazard(&candidate.replacement)
497 && !starts_with_asi_hazard(slice(source, (start, end)));
498 if !guarded {
499 return candidate.replacement.clone();
500 }
501 let joins = source
503 .as_bytes()
504 .get((start as usize).wrapping_sub(1))
505 .is_some_and(|&byte| is_identifier_byte(byte));
506 let prefix = if joins { " void " } else { "void " };
507 format!("{prefix}{}", candidate.replacement)
508}
509
510fn unique_id(
511 path: &str,
512 source: &str,
513 site: &collect::Site,
514 candidate: &collect::Candidate,
515 seen: &mut std::collections::BTreeSet<MutantId>,
516) -> MutantId {
517 let anchor_start = site.anchor.0;
518 let input = id::IdInput {
519 path,
520 anchor_text: slice(source, site.anchor),
521 relative: (
522 candidate.region.0.saturating_sub(anchor_start),
523 candidate.region.1.saturating_sub(anchor_start),
524 ),
525 operator: candidate.operator,
526 replacement: &candidate.replacement,
527 };
528 let mut occurrence = 0;
529 loop {
530 let id = id::mutant_id(&input, occurrence);
531 if seen.insert(id.clone()) {
532 return id;
533 }
534 occurrence += 1;
535 }
536}
537
538fn line_starts(source: &str) -> Vec<u32> {
539 std::iter::once(0)
540 .chain(
541 source
542 .match_indices('\n')
543 .map(|(index, _)| index as u32 + 1),
544 )
545 .collect()
546}
547
548fn line_column(line_starts: &[u32], offset: u32) -> (u32, u32) {
549 let index = line_starts
550 .partition_point(|&start| start <= offset)
551 .saturating_sub(1);
552 let start = line_starts.get(index).copied().unwrap_or(0);
553 (index as u32 + 1, offset - start)
554}