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