//! TrimDetector - Pure detection logic for trim pattern //! //! Extracted from LoopBodyCarrierPromoter to enable: //! - Single responsibility (detection only) //! - Independent unit testing //! - Reusable pattern for future analyzers //! //! # Design Philosophy //! //! This detector follows the **Detector/Recorder separation** principle: //! - **Detector**: Pure detection logic (this module) //! - **Recorder**: PromotedBindingRecorder (records BindingId mappings) //! - **Promoter**: Orchestrates detection + recording + carrier building //! //! # Pattern: A-3 Trim (Substring + Equality OR Chain) //! //! ```nyash //! loop(start < end) { //! local ch = s.substring(start, start+1) //! if ch == " " || ch == "\t" || ch == "\n" { //! start = start + 1 //! } else { //! break //! } //! } //! ``` use crate::ast::{ASTNode, BinaryOperator, LiteralValue}; /// Detection result for trim pattern. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrimDetectionResult { /// Variable name (e.g., "ch") pub match_var: String, /// Carrier name (e.g., "is_ch_match") pub carrier_name: String, /// Comparison literals (e.g., [" ", "\t", "\n", "\r"]) pub comparison_literals: Vec, } /// Pure detection logic for A-3 Trim pattern pub struct TrimDetector; impl TrimDetector { /// Detect trim pattern in condition and body. /// /// Returns None if pattern not found, Some(result) if detected. /// /// # Algorithm /// /// 1. Extract equality literals from condition (e.g., [" ", "\t"]) /// 2. Find substring() definition in loop body /// 3. Generate carrier name (e.g., "is_ch_match") /// /// # Arguments /// /// * `condition` - Break or continue condition AST node /// * `body` - Loop body statements /// * `var_name` - Variable name to check (from LoopBodyLocal analysis) /// /// # Returns /// /// * `Some(TrimDetectionResult)` if pattern detected /// * `None` if pattern not found pub fn detect( condition: &ASTNode, body: &[ASTNode], var_name: &str, ) -> Option { // Step 1: Find substring() definition for the variable let definition = Self::find_definition_in_body(body, var_name)?; // Step 2: Verify it's a substring() method call if !Self::is_substring_method_call(definition) { return None; } // Step 3: Extract equality literals from condition let literals = Self::extract_equality_literals(condition, var_name); if literals.is_empty() { return None; } // Step 4: Generate carrier name let carrier_name = format!("is_{}_match", var_name); Some(TrimDetectionResult { match_var: var_name.to_string(), carrier_name, comparison_literals: literals, }) } /// Find definition in loop body /// /// Searches for assignment: `local var = ...` or `var = ...` fn find_definition_in_body<'a>(body: &'a [ASTNode], var_name: &str) -> Option<&'a ASTNode> { let mut worklist: Vec<&'a ASTNode> = body.iter().collect(); while let Some(node) = worklist.pop() { match node { // Assignment: target = value ASTNode::Assignment { target, value, .. } => { if let ASTNode::Variable { name, .. } = target.as_ref() { if name == var_name { return Some(value.as_ref()); } } } // Local: local var = value ASTNode::Local { variables, initial_values, .. } if initial_values.len() == variables.len() => { for (i, var) in variables.iter().enumerate() { if var == var_name { if let Some(Some(init_expr)) = initial_values.get(i) { return Some(init_expr.as_ref()); } } } } // Nested structures ASTNode::If { then_body, else_body, .. } => { for stmt in then_body { worklist.push(stmt); } if let Some(else_stmts) = else_body { for stmt in else_stmts { worklist.push(stmt); } } } ASTNode::Loop { body: loop_body, .. } => { for stmt in loop_body { worklist.push(stmt); } } _ => {} } } None } /// Check if node is a substring() method call fn is_substring_method_call(node: &ASTNode) -> bool { matches!( node, ASTNode::MethodCall { method, .. } if method == "substring" ) } /// Extract equality literals from condition /// /// Handles: `ch == " " || ch == "\t" || ch == "\n"` /// /// Returns: `[" ", "\t", "\n"]` fn extract_equality_literals(cond: &ASTNode, var_name: &str) -> Vec { let mut result = Vec::new(); let mut worklist = vec![cond]; while let Some(node) = worklist.pop() { match node { // BinaryOp: Or splits, Equal compares ASTNode::BinaryOp { operator, left, right, .. } => match operator { BinaryOperator::Or => { // OR chain: traverse both sides worklist.push(left.as_ref()); worklist.push(right.as_ref()); } BinaryOperator::Equal => { // Equality comparison: extract literal if let Some(literal) = Self::extract_literal_from_equality( left.as_ref(), right.as_ref(), var_name, ) { result.push(literal); } } _ => {} }, // UnaryOp: not (...) ASTNode::UnaryOp { operand, .. } => { worklist.push(operand.as_ref()); } _ => {} } } result } /// Extract string literal from equality comparison /// /// Handles: `ch == " "` or `" " == ch` fn extract_literal_from_equality( left: &ASTNode, right: &ASTNode, var_name: &str, ) -> Option { // Pattern 1: var == literal if let ASTNode::Variable { name, .. } = left { if name == var_name { if let ASTNode::Literal { value: LiteralValue::String(s), .. } = right { return Some(s.clone()); } } } // Pattern 2: literal == var if let ASTNode::Literal { value: LiteralValue::String(s), .. } = left { if let ASTNode::Variable { name, .. } = right { if name == var_name { return Some(s.clone()); } } } None } } #[cfg(test)] mod tests { use super::*; use crate::ast::Span; fn var_node(name: &str) -> ASTNode { ASTNode::Variable { name: name.to_string(), span: Span::unknown(), } } fn string_literal(value: &str) -> ASTNode { ASTNode::Literal { value: LiteralValue::String(value.to_string()), span: Span::unknown(), } } fn method_call(object: &str, method: &str, args: Vec) -> ASTNode { ASTNode::MethodCall { object: Box::new(var_node(object)), method: method.to_string(), arguments: args, span: Span::unknown(), } } fn assignment(target: &str, value: ASTNode) -> ASTNode { ASTNode::Assignment { target: Box::new(var_node(target)), value: Box::new(value), span: Span::unknown(), } } fn equality(var: &str, literal: &str) -> ASTNode { ASTNode::BinaryOp { operator: BinaryOperator::Equal, left: Box::new(var_node(var)), right: Box::new(string_literal(literal)), span: Span::unknown(), } } fn or_op(left: ASTNode, right: ASTNode) -> ASTNode { ASTNode::BinaryOp { operator: BinaryOperator::Or, left: Box::new(left), right: Box::new(right), span: Span::unknown(), } } #[test] fn test_detect_basic_trim_pattern() { // local ch = s.substring(...) // if ch == " " || ch == "\t" { ... } let loop_body = vec![assignment("ch", method_call("s", "substring", vec![]))]; let condition = or_op(equality("ch", " "), equality("ch", "\t")); let result = TrimDetector::detect(&condition, &loop_body, "ch"); assert!(result.is_some()); let result = result.unwrap(); assert_eq!(result.match_var, "ch"); assert_eq!(result.carrier_name, "is_ch_match"); assert_eq!(result.comparison_literals.len(), 2); assert!(result.comparison_literals.contains(&" ".to_string())); assert!(result.comparison_literals.contains(&"\t".to_string())); } #[test] fn test_detect_no_match_not_substring() { // ch = s.length() // Not substring! // if ch == " " { ... } let loop_body = vec![assignment("ch", method_call("s", "length", vec![]))]; let condition = equality("ch", " "); let result = TrimDetector::detect(&condition, &loop_body, "ch"); assert!(result.is_none()); } #[test] fn test_detect_no_match_no_equality() { // local ch = s.substring(...) // if ch < "x" { ... } // Not equality! let loop_body = vec![assignment("ch", method_call("s", "substring", vec![]))]; let condition = ASTNode::BinaryOp { operator: BinaryOperator::Less, left: Box::new(var_node("ch")), right: Box::new(string_literal("x")), span: Span::unknown(), }; let result = TrimDetector::detect(&condition, &loop_body, "ch"); assert!(result.is_none()); } #[test] fn test_detect_single_equality() { // local ch = s.substring(...) // if ch == " " { ... } let loop_body = vec![assignment("ch", method_call("s", "substring", vec![]))]; let condition = equality("ch", " "); let result = TrimDetector::detect(&condition, &loop_body, "ch"); assert!(result.is_some()); let result = result.unwrap(); assert_eq!(result.comparison_literals.len(), 1); assert_eq!(result.comparison_literals[0], " "); } #[test] fn test_detect_multiple_whitespace() { // local ch = s.substring(...) // if ch == " " || ch == "\t" || ch == "\n" || ch == "\r" { ... } let loop_body = vec![assignment("ch", method_call("s", "substring", vec![]))]; let condition = or_op( or_op(equality("ch", " "), equality("ch", "\t")), or_op(equality("ch", "\n"), equality("ch", "\r")), ); let result = TrimDetector::detect(&condition, &loop_body, "ch"); assert!(result.is_some()); let result = result.unwrap(); assert_eq!(result.comparison_literals.len(), 4); assert!(result.comparison_literals.contains(&" ".to_string())); assert!(result.comparison_literals.contains(&"\t".to_string())); assert!(result.comparison_literals.contains(&"\n".to_string())); assert!(result.comparison_literals.contains(&"\r".to_string())); } #[test] fn test_detect_carrier_name_generation() { // Test carrier name generation: "ch" → "is_ch_match" let loop_body = vec![assignment("ch", method_call("s", "substring", vec![]))]; let condition = equality("ch", " "); let result = TrimDetector::detect(&condition, &loop_body, "ch").unwrap(); assert_eq!(result.carrier_name, "is_ch_match"); } #[test] fn test_detect_different_variable_names() { // Test with different variable names let test_cases = vec!["ch", "c", "char", "character"]; for var_name in test_cases { let loop_body = vec![assignment(var_name, method_call("s", "substring", vec![]))]; let condition = equality(var_name, " "); let result = TrimDetector::detect(&condition, &loop_body, var_name); assert!( result.is_some(), "Expected detection for var '{}'", var_name ); let result = result.unwrap(); assert_eq!(result.match_var, var_name); assert_eq!(result.carrier_name, format!("is_{}_match", var_name)); } } #[test] fn test_extract_literal_reversed_equality() { // Test: " " == ch (literal on left side) let loop_body = vec![assignment("ch", method_call("s", "substring", vec![]))]; let condition = ASTNode::BinaryOp { operator: BinaryOperator::Equal, left: Box::new(string_literal(" ")), right: Box::new(var_node("ch")), span: Span::unknown(), }; let result = TrimDetector::detect(&condition, &loop_body, "ch"); assert!(result.is_some()); let result = result.unwrap(); assert_eq!(result.comparison_literals.len(), 1); assert_eq!(result.comparison_literals[0], " "); } }