ryo-mutations 0.1.0

[experimental] Code transformation primitives for Rust source code
Documentation
//! ComparisonToMethodMutation: Convert comparisons to idiomatic method calls
//!
//! Transforms:
//! - `s == ""` → `s.is_empty()`
//! - `s != ""` → `!s.is_empty()`
//! - `v.len() == 0` → `v.is_empty()`
//! - `v.len() != 0` → `!v.is_empty()`
//! - `v.len() > 0` → `!v.is_empty()`
//! - `ptr == std::ptr::null()` → `ptr.is_null()` (planned)
//!
//! Corresponds to Clippy lints: `clippy::comparison_to_empty`, `clippy::len_zero`

use ryo_source::pure::{PureBlock, PureExpr, PureStmt};
use ryo_symbol::SymbolId;

use crate::Mutation;

/// Convert comparisons to idiomatic method calls
///
/// # Example
///
/// ```rust,ignore
/// use ryo_mutations::idiom::ComparisonToMethodMutation;
///
/// let mutation = ComparisonToMethodMutation::new();
/// // Transforms: if s == "" { ... }
/// // Into:       if s.is_empty() { ... }
/// ```
#[derive(Debug, Clone, Default)]
pub struct ComparisonToMethodMutation {
    /// Target function SymbolId. If None, applies to all functions.
    pub target_fn: Option<SymbolId>,
}

impl ComparisonToMethodMutation {
    pub fn new() -> Self {
        Self::default()
    }

    /// Only apply in a specific function
    pub fn in_function(mut self, id: SymbolId) -> Self {
        self.target_fn = Some(id);
        self
    }

    /// Check if expression is an empty string literal
    fn is_empty_string(expr: &PureExpr) -> bool {
        match expr {
            PureExpr::Lit(lit) => lit == "\"\"",
            _ => false,
        }
    }

    /// Check if expression is a zero literal
    fn is_zero(expr: &PureExpr) -> bool {
        match expr {
            PureExpr::Lit(lit) => lit == "0" || lit == "0usize" || lit == "0_usize",
            _ => false,
        }
    }

    /// Check if expression is a .len() call
    fn is_len_call(expr: &PureExpr) -> Option<&PureExpr> {
        match expr {
            PureExpr::MethodCall {
                receiver,
                method,
                args,
                ..
            } if method == "len" && args.is_empty() => Some(receiver.as_ref()),
            _ => None,
        }
    }

    /// Transform an expression, returns changes count
    fn transform_expr(&self, expr: &mut PureExpr) -> usize {
        let mut changes = 0;

        // Pattern: x == "" or "" == x
        if let PureExpr::Binary { op, left, right } = expr {
            let is_eq = op == "==";
            let is_neq = op == "!=";
            let is_gt = op == ">";
            let is_lt = op == "<";

            if is_eq || is_neq {
                // Check for empty string comparison
                let (target, is_empty_check) = if Self::is_empty_string(left) {
                    (right.as_ref(), true)
                } else if Self::is_empty_string(right) {
                    (left.as_ref(), true)
                } else {
                    (left.as_ref(), false)
                };

                if is_empty_check {
                    let target = target.clone();
                    let is_empty_call = PureExpr::MethodCall {
                        receiver: Box::new(target),
                        method: "is_empty".to_string(),
                        turbofish: None,
                        args: vec![],
                    };

                    *expr = if is_eq {
                        is_empty_call
                    } else {
                        PureExpr::Unary {
                            op: "!".to_string(),
                            expr: Box::new(is_empty_call),
                        }
                    };

                    return 1;
                }

                // Check for len() == 0 or len() != 0
                if let Some(receiver) = Self::is_len_call(left) {
                    if Self::is_zero(right) {
                        let is_empty_call = PureExpr::MethodCall {
                            receiver: Box::new(receiver.clone()),
                            method: "is_empty".to_string(),
                            turbofish: None,
                            args: vec![],
                        };

                        *expr = if is_eq {
                            is_empty_call
                        } else {
                            PureExpr::Unary {
                                op: "!".to_string(),
                                expr: Box::new(is_empty_call),
                            }
                        };

                        return 1;
                    }
                }

                // Check for 0 == len() or 0 != len()
                if let Some(receiver) = Self::is_len_call(right) {
                    if Self::is_zero(left) {
                        let is_empty_call = PureExpr::MethodCall {
                            receiver: Box::new(receiver.clone()),
                            method: "is_empty".to_string(),
                            turbofish: None,
                            args: vec![],
                        };

                        *expr = if is_eq {
                            is_empty_call
                        } else {
                            PureExpr::Unary {
                                op: "!".to_string(),
                                expr: Box::new(is_empty_call),
                            }
                        };

                        return 1;
                    }
                }
            }

            // Check for len() > 0 (not empty)
            if is_gt {
                if let Some(receiver) = Self::is_len_call(left) {
                    if Self::is_zero(right) {
                        let is_empty_call = PureExpr::MethodCall {
                            receiver: Box::new(receiver.clone()),
                            method: "is_empty".to_string(),
                            turbofish: None,
                            args: vec![],
                        };

                        *expr = PureExpr::Unary {
                            op: "!".to_string(),
                            expr: Box::new(is_empty_call),
                        };

                        return 1;
                    }
                }
            }

            // Check for 0 < len() (not empty)
            if is_lt {
                if let Some(receiver) = Self::is_len_call(right) {
                    if Self::is_zero(left) {
                        let is_empty_call = PureExpr::MethodCall {
                            receiver: Box::new(receiver.clone()),
                            method: "is_empty".to_string(),
                            turbofish: None,
                            args: vec![],
                        };

                        *expr = PureExpr::Unary {
                            op: "!".to_string(),
                            expr: Box::new(is_empty_call),
                        };

                        return 1;
                    }
                }
            }
        }

        // Recursively transform sub-expressions
        match expr {
            PureExpr::Binary { left, right, .. } => {
                changes += self.transform_expr(left);
                changes += self.transform_expr(right);
            }
            PureExpr::Unary { expr: inner, .. } => {
                changes += self.transform_expr(inner);
            }
            PureExpr::Call { func, args } => {
                changes += self.transform_expr(func);
                for arg in args {
                    changes += self.transform_expr(arg);
                }
            }
            PureExpr::MethodCall { receiver, args, .. } => {
                changes += self.transform_expr(receiver);
                for arg in args {
                    changes += self.transform_expr(arg);
                }
            }
            PureExpr::Block { block, .. } => {
                changes += self.transform_block(block);
            }
            PureExpr::If {
                cond,
                then_branch,
                else_branch,
            } => {
                changes += self.transform_expr(cond);
                changes += self.transform_block(then_branch);
                if let Some(else_expr) = else_branch {
                    changes += self.transform_expr(else_expr);
                }
            }
            PureExpr::Match { expr: e, arms } => {
                changes += self.transform_expr(e);
                for arm in arms {
                    changes += self.transform_expr(&mut arm.body);
                }
            }
            PureExpr::Loop { body: block, .. } | PureExpr::While { body: block, .. } => {
                changes += self.transform_block(block);
            }
            PureExpr::For {
                expr: iter_expr,
                body,
                ..
            } => {
                changes += self.transform_expr(iter_expr);
                changes += self.transform_block(body);
            }
            PureExpr::Closure { body, .. } => {
                changes += self.transform_expr(body);
            }
            _ => {}
        }

        changes
    }

    pub fn transform_block(&self, block: &mut PureBlock) -> usize {
        let mut changes = 0;
        for stmt in &mut block.stmts {
            changes += self.transform_stmt(stmt);
        }
        changes
    }

    fn transform_stmt(&self, stmt: &mut PureStmt) -> usize {
        match stmt {
            PureStmt::Local { init: Some(e), .. } => self.transform_expr(e),
            PureStmt::Semi(e) | PureStmt::Expr(e) => self.transform_expr(e),
            _ => 0,
        }
    }
}

impl Mutation for ComparisonToMethodMutation {
    fn describe(&self) -> String {
        "Convert comparisons to method calls (s == \"\" → s.is_empty())".to_string()
    }

    fn mutation_type(&self) -> &'static str {
        "ComparisonToMethod"
    }

    fn box_clone(&self) -> Box<dyn Mutation> {
        Box::new(self.clone())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_empty_string() {
        assert!(ComparisonToMethodMutation::is_empty_string(&PureExpr::Lit(
            "\"\"".to_string()
        )));
        assert!(!ComparisonToMethodMutation::is_empty_string(
            &PureExpr::Lit("\"hello\"".to_string())
        ));
    }

    #[test]
    fn test_is_zero() {
        assert!(ComparisonToMethodMutation::is_zero(&PureExpr::Lit(
            "0".to_string()
        )));
        assert!(ComparisonToMethodMutation::is_zero(&PureExpr::Lit(
            "0usize".to_string()
        )));
        assert!(!ComparisonToMethodMutation::is_zero(&PureExpr::Lit(
            "1".to_string()
        )));
    }

    #[test]
    fn test_is_len_call() {
        let len_call = PureExpr::MethodCall {
            receiver: Box::new(PureExpr::Path("v".to_string())),
            method: "len".to_string(),
            turbofish: None,
            args: vec![],
        };
        assert!(ComparisonToMethodMutation::is_len_call(&len_call).is_some());

        let not_len = PureExpr::MethodCall {
            receiver: Box::new(PureExpr::Path("v".to_string())),
            method: "size".to_string(),
            turbofish: None,
            args: vec![],
        };
        assert!(ComparisonToMethodMutation::is_len_call(&not_len).is_none());
    }
}