Skip to main content

forge/mutation/
visitor.rs

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