Skip to main content

forge/mutation/
visitor.rs

1use crate::mutation::{
2    mutant::{Mutant, OwnedLiteral},
3    mutators::{MutationContext, mutator_registry::MutatorRegistry},
4    type_analysis::{AssignmentReplacement, MutationExclusion, MutationExclusionSet},
5};
6use eyre::Report;
7use foundry_config::MutatorType;
8use solar::ast::{Expr, ItemContract, ItemFunction, VariableDefinition, visit::Visit, yul};
9use std::{ops::ControlFlow, path::PathBuf};
10
11#[cfg(test)]
12use crate::mutation::mutators::Mutator;
13
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub enum AssignVarTypes {
16    Literal(OwnedLiteral),
17    Identifier(String),
18    NegatedIdentifier(String),
19}
20
21/// A visitor which collect all expression to mutate as well as the mutation types
22#[allow(clippy::type_complexity)]
23pub struct MutantVisitor<'src> {
24    pub mutation_to_conduct: Vec<Mutant>,
25    errors: Vec<Report>,
26    pub mutator_registry: MutatorRegistry,
27    pub path: PathBuf,
28    pub source: Option<&'src str>,
29    /// Optional per-contract name filter. When `Some`, mutations are only collected
30    /// from contracts whose name matches the predicate.
31    pub contract_filter: Option<Box<dyn Fn(&str) -> bool>>,
32    /// Whether the currently-visited contract is allowed by `contract_filter`.
33    /// `true` when no filter is set or when we are visiting a contract whose name
34    /// matched the filter. Top-level items (outside any contract) are always
35    /// considered "allowed".
36    in_allowed_contract: bool,
37    mutation_exclusions: MutationExclusionSet,
38}
39
40impl<'src> MutantVisitor<'src> {
41    /// Create a visitor with the specified mutator operators enabled
42    pub fn with_operators(path: PathBuf, operators: &[MutatorType]) -> Self {
43        Self {
44            mutation_to_conduct: Vec::new(),
45            errors: Vec::new(),
46            mutator_registry: MutatorRegistry::from_enabled(operators),
47            path,
48            source: None,
49            contract_filter: None,
50            in_allowed_contract: true,
51            mutation_exclusions: MutationExclusionSet::new(),
52        }
53    }
54
55    /// Use only a set of mutators
56    #[cfg(test)]
57    pub fn new_with_mutators(path: PathBuf, mutators: Vec<Box<dyn Mutator>>) -> Self {
58        Self {
59            mutator_registry: MutatorRegistry::new_with_mutators(mutators),
60            ..Self::with_operators(path, &[])
61        }
62    }
63
64    /// Set the source code for extracting original text
65    pub const fn with_source(mut self, source: &'src str) -> Self {
66        self.source = Some(source);
67        self
68    }
69
70    /// Set a contract-name filter; only contracts whose name matches the
71    /// predicate will have their bodies mutated.
72    pub fn with_contract_filter<F>(mut self, filter: F) -> Self
73    where
74        F: Fn(&str) -> bool + 'static,
75    {
76        self.contract_filter = Some(Box::new(filter));
77        self
78    }
79
80    /// Exclude operator replacements rejected by type analysis.
81    pub fn with_mutation_exclusions(mut self, mutations: MutationExclusionSet) -> Self {
82        self.mutation_exclusions = mutations;
83        self
84    }
85
86    pub fn take_errors(&mut self) -> Vec<Report> {
87        std::mem::take(&mut self.errors)
88    }
89
90    fn collect_mutations(&mut self, context: &MutationContext<'_>) {
91        let result = self.mutator_registry.generate_mutations(context);
92        self.mutation_to_conduct.extend(result.mutations.into_iter().filter(|mutant| {
93            let exclusion = match &mutant.mutation {
94                crate::mutation::mutant::MutationType::Assignment(kind) => {
95                    let replacement = match kind {
96                        AssignVarTypes::Literal(OwnedLiteral::Number(value)) if value.is_zero() => {
97                            AssignmentReplacement::Zero
98                        }
99                        AssignVarTypes::Literal(OwnedLiteral::NegatedNumber(_))
100                        | AssignVarTypes::NegatedIdentifier(_) => AssignmentReplacement::Negate,
101                        _ => return true,
102                    };
103                    MutationExclusion::assignment(mutant.span, replacement)
104                }
105                crate::mutation::mutant::MutationType::BinaryOpExpr { new_op, .. } => {
106                    MutationExclusion::binary(mutant.span, *new_op)
107                }
108                crate::mutation::mutant::MutationType::UnaryOperator(unary) => {
109                    MutationExclusion::unary(mutant.span, unary.resulting_op_kind)
110                }
111                _ => return true,
112            };
113            !self.mutation_exclusions.contains(&exclusion)
114        }));
115
116        for err in result.errors {
117            self.errors.push(err.wrap_err(format!(
118                "failed to generate mutations for {}:{}:{}",
119                self.path.display(),
120                context.line_number(),
121                context.column_number()
122            )));
123        }
124    }
125}
126
127impl<'ast> Visit<'ast> for MutantVisitor<'ast> {
128    type BreakValue = ();
129
130    fn visit_item_contract(
131        &mut self,
132        contract: &'ast ItemContract<'ast>,
133    ) -> ControlFlow<Self::BreakValue> {
134        // When a contract name filter is configured, only descend into matching
135        // contracts. We toggle `in_allowed_contract` for the duration of the
136        // walk so nested visit_expr / visit_variable_definition calls can gate
137        // mutant collection accordingly.
138        let prev = self.in_allowed_contract;
139        self.in_allowed_contract = match &self.contract_filter {
140            Some(filter) => filter(contract.name.as_str()),
141            None => true,
142        };
143        let res = self.walk_item_contract(contract);
144        self.in_allowed_contract = prev;
145        res
146    }
147
148    fn visit_item_function(
149        &mut self,
150        function: &'ast ItemFunction<'ast>,
151    ) -> ControlFlow<Self::BreakValue> {
152        if self.in_allowed_contract && function.body.is_some() {
153            let mut builder = MutationContext::builder()
154                .with_path(self.path.clone())
155                .with_span(function.body_span)
156                .with_function(function);
157            if let Some(src) = self.source {
158                builder = builder.with_source(src);
159            }
160            self.collect_mutations(&builder.build().expect("function body context"));
161        }
162        self.walk_item_function(function)
163    }
164
165    fn visit_variable_definition(
166        &mut self,
167        var: &'ast VariableDefinition<'ast>,
168    ) -> ControlFlow<Self::BreakValue> {
169        // Skip entirely when the surrounding contract is filtered out.
170        if !self.in_allowed_contract {
171            return self.walk_variable_definition(var);
172        }
173
174        let mut builder = MutationContext::builder()
175            .with_path(self.path.clone())
176            .with_span(var.span)
177            .with_var_definition(var);
178
179        if let Some(src) = self.source {
180            builder = builder.with_source(src);
181        }
182
183        let context = builder
184            .build()
185            .expect("MutationContext requires both path and span for variable definition");
186
187        self.collect_mutations(&context);
188        self.walk_variable_definition(var)
189    }
190
191    fn visit_expr(&mut self, expr: &'ast Expr<'ast>) -> ControlFlow<Self::BreakValue> {
192        // Skip entirely when the surrounding contract is filtered out.
193        if !self.in_allowed_contract {
194            return self.walk_expr(expr);
195        }
196
197        let mut builder = MutationContext::builder()
198            .with_path(self.path.clone())
199            .with_span(expr.span)
200            .with_expr(expr);
201
202        if let Some(src) = self.source {
203            builder = builder.with_source(src);
204        }
205
206        let context =
207            builder.build().expect("MutationContext requires both path and span for expression");
208
209        self.collect_mutations(&context);
210        self.walk_expr(expr)
211    }
212
213    fn visit_yul_expr(&mut self, expr: &'ast yul::Expr<'ast>) -> ControlFlow<Self::BreakValue> {
214        // Skip entirely when the surrounding contract is filtered out.
215        if !self.in_allowed_contract {
216            return self.walk_yul_expr(expr);
217        }
218
219        let mut builder = MutationContext::builder()
220            .with_path(self.path.clone())
221            .with_span(expr.span)
222            .with_yul_expr(expr);
223
224        if let Some(src) = self.source {
225            builder = builder.with_source(src);
226        }
227
228        let context = builder
229            .build()
230            .expect("MutationContext requires both path and span for yul expression");
231
232        self.collect_mutations(&context);
233        self.walk_yul_expr(expr)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use eyre::{Result, eyre};
240    use solar::{
241        ast::{Arena, interface::source_map::FileName},
242        parse::Parser,
243    };
244
245    use super::*;
246    use crate::mutation::{Session, mutant::MutationType};
247
248    struct FailingExprMutator;
249
250    impl Mutator for FailingExprMutator {
251        fn generate_mutants(&self, _ctxt: &MutationContext<'_>) -> Result<Vec<Mutant>> {
252            Err(eyre!("synthetic visitor failure"))
253        }
254
255        fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool {
256            ctxt.expr.is_some()
257        }
258    }
259
260    struct PassingExprMutator;
261
262    impl Mutator for PassingExprMutator {
263        fn generate_mutants(&self, ctxt: &MutationContext<'_>) -> Result<Vec<Mutant>> {
264            Ok(vec![Mutant {
265                path: ctxt.path.clone(),
266                span: ctxt.span,
267                mutation: MutationType::DeleteExpression,
268                original: ctxt.original_text(),
269                source_line: ctxt.source_line(),
270                line_number: ctxt.line_number(),
271                column_number: ctxt.column_number(),
272            }])
273        }
274
275        fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool {
276            ctxt.expr.is_some()
277        }
278    }
279
280    #[test]
281    fn visitor_collects_mutations_and_surfaces_mutator_errors() {
282        let source = "\
283// SPDX-License-Identifier: MIT
284pragma solidity ^0.8.0;
285contract Test {
286    function test() public {
287        uint256 x = 1 + 2;
288    }
289}
290";
291        let path = PathBuf::from("test.sol");
292        let sess = Session::builder().with_silent_emitter(None).build();
293
294        sess.enter(|| {
295            let arena = Arena::new();
296            let mut parser =
297                Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || {
298                    Ok(source.to_string())
299                })
300                .unwrap();
301            let ast = parser.parse_file().map_err(|e| e.emit()).unwrap();
302            drop(parser);
303            let mut visitor = MutantVisitor::new_with_mutators(
304                path,
305                vec![Box::new(FailingExprMutator), Box::new(PassingExprMutator)],
306            )
307            .with_source(source);
308
309            let _ = visitor.visit_source_unit(&ast);
310            let errors = visitor.take_errors();
311
312            assert!(!visitor.mutation_to_conduct.is_empty());
313            assert!(!errors.is_empty());
314
315            let err = format!("{:?}", errors[0]);
316            assert!(err.contains("failed to generate mutations for test.sol:"));
317            assert!(err.contains("synthetic visitor failure"));
318        });
319    }
320
321    #[test]
322    fn visitor_excludes_binary_mutations_rejected_by_type_analysis() {
323        let source = "\
324pragma solidity ^0.8.0;
325contract Test {
326    function check(uint256 x) public pure returns (bool) {
327        return x == 0;
328    }
329}
330";
331        let path = PathBuf::from("test.sol");
332        let lo = source.find("x == 0").unwrap() as u32;
333        let span = solar::ast::Span::new(
334            solar::interface::BytePos(lo),
335            solar::interface::BytePos(lo + "x == 0".len() as u32),
336        );
337        let mutation_exclusions =
338            [MutationExclusion::binary(span, solar::ast::BinOpKind::Le)].into_iter().collect();
339        let sess = Session::builder().with_silent_emitter(None).build();
340
341        sess.enter(|| {
342            let arena = Arena::new();
343            let mut parser =
344                Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || {
345                    Ok(source.to_string())
346                })
347                .unwrap();
348            let ast = parser.parse_file().map_err(|e| e.emit()).unwrap();
349            drop(parser);
350            let mut visitor = MutantVisitor::with_operators(path, &MutatorType::all())
351                .with_source(source)
352                .with_mutation_exclusions(mutation_exclusions);
353
354            let _ = visitor.visit_source_unit(&ast);
355            let comparison_mutations = visitor.mutation_to_conduct.iter().filter_map(|mutant| {
356                let MutationType::BinaryOpExpr { new_op, .. } = mutant.mutation else {
357                    return None;
358                };
359                (mutant.span == span).then_some(new_op)
360            });
361            let comparison_mutations = comparison_mutations.collect::<Vec<_>>();
362
363            assert!(!comparison_mutations.contains(&solar::ast::BinOpKind::Le));
364            assert!(comparison_mutations.contains(&solar::ast::BinOpKind::Lt));
365        });
366    }
367
368    #[test]
369    fn visitor_excludes_assignment_mutations_rejected_by_type_analysis() {
370        let source = "\
371pragma solidity ^0.8.0;
372contract Test {
373    function check(address account) public pure {
374        address copy = account;
375    }
376}
377";
378        let path = PathBuf::from("test.sol");
379        let lo = source.rfind("account").unwrap() as u32;
380        let span = solar::ast::Span::new(
381            solar::interface::BytePos(lo),
382            solar::interface::BytePos(lo + "account".len() as u32),
383        );
384        let mutation_exclusions = [
385            MutationExclusion::assignment(span, AssignmentReplacement::Zero),
386            MutationExclusion::assignment(span, AssignmentReplacement::Negate),
387        ]
388        .into_iter()
389        .collect();
390        let sess = Session::builder().with_silent_emitter(None).build();
391
392        sess.enter(|| {
393            let arena = Arena::new();
394            let mut parser =
395                Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || {
396                    Ok(source.to_string())
397                })
398                .unwrap();
399            let ast = parser.parse_file().map_err(|e| e.emit()).unwrap();
400            drop(parser);
401            let mut visitor = MutantVisitor::new_with_mutators(
402                path,
403                vec![Box::new(crate::mutation::mutators::assignment_mutator::AssignmentMutator)],
404            )
405            .with_source(source)
406            .with_mutation_exclusions(mutation_exclusions);
407
408            let _ = visitor.visit_source_unit(&ast);
409
410            assert!(visitor.mutation_to_conduct.iter().all(|mutant| mutant.span != span));
411        });
412    }
413
414    #[test]
415    fn visitor_excludes_negated_number_but_retains_zero_assignment() {
416        let source = "\
417pragma solidity ^0.8.0;
418contract Test {
419    function check() public pure {
420        uint8 copy = 1;
421    }
422}
423";
424        let path = PathBuf::from("test.sol");
425        let lo = source.rfind('1').unwrap() as u32;
426        let span =
427            solar::ast::Span::new(solar::interface::BytePos(lo), solar::interface::BytePos(lo + 1));
428        let mutation_exclusions =
429            [MutationExclusion::assignment(span, AssignmentReplacement::Negate)]
430                .into_iter()
431                .collect();
432        let sess = Session::builder().with_silent_emitter(None).build();
433
434        sess.enter(|| {
435            let arena = Arena::new();
436            let mut parser =
437                Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || {
438                    Ok(source.to_string())
439                })
440                .unwrap();
441            let ast = parser.parse_file().map_err(|e| e.emit()).unwrap();
442            drop(parser);
443            let mut visitor = MutantVisitor::new_with_mutators(
444                path,
445                vec![Box::new(crate::mutation::mutators::assignment_mutator::AssignmentMutator)],
446            )
447            .with_source(source)
448            .with_mutation_exclusions(mutation_exclusions);
449
450            let _ = visitor.visit_source_unit(&ast);
451            let replacements = visitor
452                .mutation_to_conduct
453                .iter()
454                .filter(|mutant| mutant.span == span)
455                .map(|mutant| mutant.mutation.to_string())
456                .collect::<Vec<_>>();
457
458            assert_eq!(replacements, ["0"]);
459        });
460    }
461
462    #[test]
463    fn visitor_excludes_unsigned_negation_but_retains_unary_swaps() {
464        let source = "\
465pragma solidity ^0.8.0;
466contract Test {
467    function increment(uint256 x) public pure {
468        x++;
469    }
470}
471";
472        let path = PathBuf::from("test.sol");
473        let lo = source.find("x++").unwrap() as u32;
474        let span = solar::ast::Span::new(
475            solar::interface::BytePos(lo),
476            solar::interface::BytePos(lo + "x++".len() as u32),
477        );
478        let mutation_exclusions =
479            [MutationExclusion::unary(span, solar::ast::UnOpKind::Neg)].into_iter().collect();
480        let sess = Session::builder().with_silent_emitter(None).build();
481
482        sess.enter(|| {
483            let arena = Arena::new();
484            let mut parser =
485                Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || {
486                    Ok(source.to_string())
487                })
488                .unwrap();
489            let ast = parser.parse_file().map_err(|e| e.emit()).unwrap();
490            drop(parser);
491            let mut visitor = MutantVisitor::new_with_mutators(
492                path,
493                vec![Box::new(crate::mutation::mutators::unary_op_mutator::UnaryOpMutator)],
494            )
495            .with_source(source)
496            .with_mutation_exclusions(mutation_exclusions);
497
498            let _ = visitor.visit_source_unit(&ast);
499            let unary_mutations = visitor
500                .mutation_to_conduct
501                .iter()
502                .filter_map(|mutant| {
503                    let MutationType::UnaryOperator(unary) = &mutant.mutation else {
504                        return None;
505                    };
506                    (mutant.span == span).then_some(unary.resulting_op_kind)
507                })
508                .collect::<Vec<_>>();
509
510            assert_eq!(unary_mutations.len(), 4);
511            assert!(!unary_mutations.contains(&solar::ast::UnOpKind::Neg));
512            assert!(unary_mutations.contains(&solar::ast::UnOpKind::PreInc));
513            assert!(unary_mutations.contains(&solar::ast::UnOpKind::PreDec));
514            assert!(unary_mutations.contains(&solar::ast::UnOpKind::BitNot));
515            assert!(unary_mutations.contains(&solar::ast::UnOpKind::PostDec));
516        });
517    }
518
519    #[test]
520    fn function_body_replacements_preserve_headers_and_skip_unsupported_functions() {
521        let source = r#"
522abstract contract Target {
523    constructor() { revert(); }
524    modifier guarded() { require(true); _; }
525    function effect() public guarded { revert(); }
526    function boolean() external pure returns (bool) { return 2 > 1; }
527    function unsigned() internal pure returns (uint8 result) { result = 7; }
528    function signed() private pure returns (int256) { return -7; }
529    function account() public view returns (address) { return msg.sender; }
530    function payableAccount() public view returns (address payable) { return payable(msg.sender); }
531    function tuple() public virtual returns (uint, bool);
532    function dynamic() public pure returns (bytes memory) { return hex"1234"; }
533    function multiple() public pure returns (uint, bool) { return (7, true); }
534    function testSomething() public { revert(); }
535    function invariantSomething() public { revert(); }
536    function setUp() public { revert(); }
537    function empty() public {}
538    function emptyCommented() public { /* No work. */ }
539    function implicitReturn() public pure returns (uint256 result) {}
540    receive() external payable { revert(); }
541    fallback() external { revert(); }
542}
543contract Other { function ignored() public { revert(); } }
544"#;
545        let mutants = function_body_mutants(source);
546        let first = &mutants[0];
547        let mut mutated = source.to_string();
548        mutated.replace_range(
549            first.span.lo().0 as usize..first.span.hi().0 as usize,
550            &first.mutation.to_string(),
551        );
552        assert_eq!(
553            mutated,
554            source.replacen(
555                "function effect() public guarded { revert(); }",
556                "function effect() public guarded {}",
557                1,
558            )
559        );
560        assert_eq!(
561            mutants.iter().map(|mutant| mutant.mutation.to_string()).collect::<Vec<_>>(),
562            [
563                "{}",
564                "{ return true; }",
565                "{ return false; }",
566                "{ return 0; }",
567                "{ return 1; }",
568                "{ return 0; }",
569                "{ return 1; }",
570                "{ return -1; }",
571                "{ return address(0); }",
572                "{ return payable(address(0)); }",
573                "{}",
574                "{}",
575                "{}",
576                "{ return 1; }",
577            ]
578        );
579    }
580
581    #[test]
582    fn function_body_replacements_skip_equivalent_constants() {
583        for (ty, expression, expected) in [
584            ("bool", "(true)", vec!["false"]),
585            ("bool", "(/* Always on. */ true)", vec!["false"]),
586            ("bool", "bool((false))", vec!["true"]),
587            ("uint256", "0x0", vec!["1"]),
588            ("uint8", "uint8(0)", vec!["1"]),
589            ("uint256", "1_0e-1", vec!["0"]),
590            ("int256", "-(int8((0x1)))", vec!["0", "1"]),
591            ("int256", "int256(-1)", vec!["0", "1"]),
592            ("int256", "-(-1)", vec!["0", "-1"]),
593            ("int256", "-0", vec!["1", "-1"]),
594            ("address", "address(uint160(0x0))", vec![]),
595            ("address payable", "payable(address((0)))", vec![]),
596            // Keep replacements when evaluating the original can have side effects.
597            ("bool", "other()", vec!["true", "false"]),
598            ("uint8", "uint8(other())", vec!["0", "1"]),
599        ] {
600            let source = format!(
601                "contract Target {{ function target() public returns ({ty}) {{ return {expression}; }} }}"
602            );
603            let replacements = function_body_mutants(&source)
604                .iter()
605                .map(|mutant| mutant.mutation.to_string())
606                .collect::<Vec<_>>();
607            assert_eq!(
608                replacements,
609                expected.iter().map(|value| format!("{{ return {value}; }}")).collect::<Vec<_>>(),
610                "{source}"
611            );
612        }
613    }
614
615    fn function_body_mutants(source: &str) -> Vec<Mutant> {
616        let sess = Session::builder().with_silent_emitter(None).build();
617        sess.enter(|| {
618            let arena = Arena::new();
619            let mut parser = Parser::from_lazy_source_code(
620                &sess,
621                &arena,
622                FileName::Real("target.sol".into()),
623                || Ok(source.to_string()),
624            )
625            .unwrap();
626            let ast = parser.parse_file().map_err(|e| e.emit()).unwrap();
627            drop(parser);
628            let mut visitor =
629                MutantVisitor::with_operators("target.sol".into(), &[MutatorType::FunctionBody])
630                    .with_source(source)
631                    .with_contract_filter(|name| name == "Target");
632            let _ = visitor.visit_source_unit(&ast);
633            assert!(visitor.take_errors().is_empty());
634            for mutant in &visitor.mutation_to_conduct {
635                assert_eq!(
636                    mutant.original,
637                    &source[mutant.span.lo().0 as usize..mutant.span.hi().0 as usize]
638                );
639            }
640            visitor.mutation_to_conduct
641        })
642    }
643}