bcore_mutation/
mutation.rs

1use crate::ast_analysis::{filter_mutatable_lines, AridNodeDetector};
2use crate::error::{MutationError, Result};
3use crate::git_changes::{get_changed_files, get_lines_touched};
4use crate::operators::{
5    get_do_not_mutate_patterns, get_do_not_mutate_py_patterns, get_do_not_mutate_unit_patterns,
6    get_regex_operators, get_security_operators, get_skip_if_contain_patterns, get_test_operators,
7    should_mutate_test_line,
8};
9use regex::Regex;
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug)]
15pub struct FileToMutate {
16    pub file_path: String,
17    pub lines_touched: Vec<usize>,
18    pub is_unit_test: bool,
19}
20
21pub async fn run_mutation(
22    pr_number: Option<u32>,
23    file: Option<PathBuf>,
24    one_mutant: bool,
25    only_security_mutations: bool,
26    range_lines: Option<(usize, usize)>,
27    coverage: Option<HashMap<String, Vec<usize>>>,
28    test_only: bool,
29    skip_lines: HashMap<String, Vec<usize>>,
30    enable_ast_filtering: bool,
31    custom_expert_rule: Option<String>,
32) -> Result<()> {
33    if let Some(file_path) = file {
34        let file_str = file_path.to_string_lossy().to_string();
35        let is_unit_test = file_str.contains("test") && !file_str.contains(".py");
36
37        mutate_file(
38            &file_str,
39            None,
40            None,
41            one_mutant,
42            only_security_mutations,
43            range_lines,
44            &coverage,
45            is_unit_test,
46            &skip_lines,
47            enable_ast_filtering,
48            custom_expert_rule,
49        )
50        .await?;
51        return Ok(());
52    }
53
54    let files_changed = get_changed_files(pr_number).await?;
55    let mut files_to_mutate = Vec::new();
56
57    for file_changed in files_changed {
58        // Skip certain file types
59        if file_changed.contains("doc")
60            || file_changed.contains("fuzz")
61            || file_changed.contains("bench")
62            || file_changed.contains("util")
63            || file_changed.contains("sanitizer_supressions")
64            || file_changed.ends_with(".txt")
65        {
66            continue;
67        }
68
69        let lines_touched = get_lines_touched(&file_changed).await?;
70        let is_unit_test = file_changed.contains("test")
71            && !file_changed.contains(".py")
72            && !file_changed.contains("util");
73
74        if test_only && !(is_unit_test || file_changed.contains(".py")) {
75            continue;
76        }
77
78        files_to_mutate.push(FileToMutate {
79            file_path: file_changed,
80            lines_touched,
81            is_unit_test,
82        });
83    }
84
85    for file_info in files_to_mutate {
86        mutate_file(
87            &file_info.file_path,
88            Some(file_info.lines_touched),
89            pr_number,
90            one_mutant,
91            only_security_mutations,
92            range_lines,
93            &coverage,
94            file_info.is_unit_test,
95            &skip_lines,
96            enable_ast_filtering,
97            custom_expert_rule.clone(),
98        )
99        .await?;
100    }
101
102    Ok(())
103}
104
105pub async fn mutate_file(
106    file_to_mutate: &str,
107    touched_lines: Option<Vec<usize>>,
108    pr_number: Option<u32>,
109    one_mutant: bool,
110    only_security_mutations: bool,
111    range_lines: Option<(usize, usize)>,
112    coverage: &Option<HashMap<String, Vec<usize>>>,
113    is_unit_test: bool,
114    skip_lines: &HashMap<String, Vec<usize>>,
115    enable_ast_filtering: bool,
116    custom_expert_rule: Option<String>,
117) -> Result<()> {
118    println!("\n\nGenerating mutants for {}...", file_to_mutate);
119
120    let source_code = fs::read_to_string(file_to_mutate)?;
121    let lines: Vec<&str> = source_code.lines().collect();
122    println!("File has {} lines", lines.len());
123
124    // Initialize AST-based arid node detection for C++ files
125    let mut arid_detector = if enable_ast_filtering
126        && (file_to_mutate.ends_with(".cpp") || file_to_mutate.ends_with(".h"))
127    {
128        let mut detector = AridNodeDetector::new()?;
129
130        // Add custom expert rule if provided
131        if let Some(rule) = custom_expert_rule {
132            detector.add_expert_rule(&rule, "Custom user rule")?;
133        }
134
135        Some(detector)
136    } else {
137        if !enable_ast_filtering {
138            println!("AST filtering disabled - generating all possible mutants");
139        }
140        None
141    };
142
143    // Filter out arid lines using AST analysis (for C++ files)
144    let ast_filtered_lines = if let Some(ref mut detector) = arid_detector {
145        let string_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
146        let mutatable_line_numbers = filter_mutatable_lines(&string_lines, detector);
147        println!(
148            "AST analysis filtered to {} mutatable lines (from {})",
149            mutatable_line_numbers.len(),
150            lines.len()
151        );
152
153        // Show some examples of filtered out lines
154        let filtered_out_count = lines.len() - mutatable_line_numbers.len();
155        if filtered_out_count > 0 {
156            println!(
157                "Filtered out {} arid lines (logging, reserve calls, etc.)",
158                filtered_out_count
159            );
160        }
161
162        Some(mutatable_line_numbers)
163    } else {
164        None
165    };
166
167    // Select operators based on file type and options
168    let operators = if only_security_mutations {
169        println!("Using security operators");
170        get_security_operators()?
171    } else if file_to_mutate.contains(".py") || is_unit_test {
172        println!("Using test operators (Python or unit test file)");
173        get_test_operators()?
174    } else {
175        println!("Using regex operators");
176        get_regex_operators()?
177    };
178
179    println!("Loaded {} operators", operators.len());
180
181    let skip_lines_for_file = skip_lines.get(file_to_mutate);
182    let mut touched_lines = touched_lines.unwrap_or_else(|| (1..=lines.len()).collect());
183
184    // Apply AST filtering if available
185    if let Some(ast_lines) = ast_filtered_lines {
186        // Intersect touched_lines with AST-filtered lines
187        touched_lines.retain(|line_num| ast_lines.contains(line_num));
188        println!(
189            "After AST filtering: {} lines to process",
190            touched_lines.len()
191        );
192    }
193
194    // Get coverage data for this file
195    let lines_with_test_coverage = if let Some(cov) = coverage {
196        cov.iter()
197            .find(|(path, _)| file_to_mutate.contains(path.as_str()))
198            .map(|(_, lines)| lines.clone())
199            .unwrap_or_default()
200    } else {
201        Vec::new()
202    };
203
204    if !lines_with_test_coverage.is_empty() {
205        println!(
206            "Using coverage data with {} covered lines",
207            lines_with_test_coverage.len()
208        );
209    }
210
211    let mut mutant_count = 0;
212
213    if one_mutant {
214        println!("One mutant mode enabled");
215    }
216
217    for line_num in touched_lines {
218        let line_idx = line_num.saturating_sub(1);
219
220        // Check coverage if provided
221        if !lines_with_test_coverage.is_empty() && !lines_with_test_coverage.contains(&line_num) {
222            continue;
223        }
224
225        // Check range if provided
226        if let Some((start, end)) = range_lines {
227            if line_idx < start || line_idx > end {
228                continue;
229            }
230        }
231
232        // Check skip lines (skip_lines uses 1-indexed line numbers)
233        if let Some(skip) = skip_lines_for_file {
234            if skip.contains(&line_num) {
235                continue;
236            }
237        }
238
239        if line_idx >= lines.len() {
240            continue;
241        }
242
243        let line_before_mutation = lines[line_idx];
244
245        // Check if line should be skipped (traditional approach)
246        if should_skip_line(line_before_mutation, file_to_mutate, is_unit_test)? {
247            continue;
248        }
249
250        let mut line_had_match = false;
251
252        for operator in &operators {
253            // Special handling for test operators
254            if file_to_mutate.contains(".py") || is_unit_test {
255                if !should_mutate_test_line(line_before_mutation) {
256                    continue;
257                }
258            }
259
260            if operator.pattern.is_match(line_before_mutation) {
261                line_had_match = true;
262                let line_mutated = operator
263                    .pattern
264                    .replace(line_before_mutation, &operator.replacement);
265
266                // Create mutated file content
267                let mut mutated_lines = lines.clone();
268                mutated_lines[line_idx] = &line_mutated;
269                let mutated_content = mutated_lines.join("\n");
270
271                mutant_count = write_mutation(
272                    file_to_mutate,
273                    &mutated_content,
274                    mutant_count,
275                    pr_number,
276                    range_lines,
277                )?;
278
279                if one_mutant {
280                    break; // Break only from operator loop, continue to next line
281                }
282            }
283        }
284
285        // Debug output for lines that didn't match any patterns
286        if !line_had_match && !line_before_mutation.trim().is_empty() {
287            println!(
288                "Line {} '{}' didn't match any patterns",
289                line_num,
290                line_before_mutation.trim()
291            );
292        }
293
294        // Note: Removed the early break that was stopping line processing
295        // Now each line gets processed independently
296    }
297
298    // Print AST analysis statistics
299    if let Some(detector) = arid_detector {
300        let stats = detector.get_stats();
301        println!("AST Analysis Stats: {:?}", stats);
302    }
303
304    println!("Generated {} mutants...", mutant_count);
305    Ok(())
306}
307
308fn should_skip_line(line: &str, file_path: &str, is_unit_test: bool) -> Result<bool> {
309    let trimmed = line.trim_start();
310
311    // Check basic patterns to skip
312    for pattern in get_do_not_mutate_patterns() {
313        if trimmed.starts_with(pattern) {
314            return Ok(true);
315        }
316    }
317
318    // Check skip if contain patterns
319    for pattern in get_skip_if_contain_patterns() {
320        if line.contains(pattern) {
321            return Ok(true);
322        }
323    }
324
325    // Language-specific checks
326    if file_path.contains(".py") || is_unit_test {
327        let patterns = if is_unit_test {
328            get_do_not_mutate_unit_patterns()
329        } else {
330            get_do_not_mutate_py_patterns()
331        };
332
333        for pattern in patterns {
334            if line.contains(pattern) {
335                return Ok(true);
336            }
337        }
338
339        // Check for assignment patterns
340        let assignment_regex = if is_unit_test {
341            Regex::new(
342                r"\b(?:[a-zA-Z_][a-zA-Z0-9_:<>*&\s]+)\s+[a-zA-Z_][a-zA-Z0-9_]*(?:\[[^\]]*\])?(?:\.(?:[a-zA-Z_][a-zA-Z0-9_]*)|\->(?:[a-zA-Z_][a-zA-Z0-9_]*))*(?:\s*=\s*[^;]+|\s*\{[^;]+\})\s*",
343            )?
344        } else {
345            Regex::new(r"^\s*([a-zA-Z_]\w*)\s*=\s*(.+)$")?
346        };
347
348        if assignment_regex.is_match(line) {
349            return Ok(true);
350        }
351    }
352
353    Ok(false)
354}
355
356fn write_mutation(
357    file_to_mutate: &str,
358    mutated_content: &str,
359    mutant_index: usize,
360    pr_number: Option<u32>,
361    range_lines: Option<(usize, usize)>,
362) -> Result<usize> {
363    let file_extension = if file_to_mutate.ends_with(".h") {
364        ".h"
365    } else if file_to_mutate.ends_with(".py") {
366        ".py"
367    } else {
368        ".cpp"
369    };
370
371    let file_name = Path::new(file_to_mutate)
372        .file_stem()
373        .and_then(|s| s.to_str())
374        .ok_or_else(|| MutationError::InvalidInput("Invalid file path".to_string()))?;
375
376    let ext = file_extension.trim_start_matches('.');
377    let folder = if let Some(pr) = pr_number {
378        format!("muts-pr-{}-{}-{}", pr, file_name, ext)
379    } else if let Some(range) = range_lines {
380        format!("muts-pr-{}-{}-{}", file_name, range.0, range.1)
381    } else {
382        format!("muts-{}-{}", file_name, ext)
383    };
384
385    create_mutation_folder(&folder, file_to_mutate)?;
386
387    let mutator_file = format!(
388        "{}/{}.mutant.{}{}",
389        folder, file_name, mutant_index, file_extension
390    );
391    fs::write(mutator_file, mutated_content)?;
392
393    Ok(mutant_index + 1)
394}
395
396fn create_mutation_folder(folder_name: &str, file_to_mutate: &str) -> Result<()> {
397    let folder_path = Path::new(folder_name);
398
399    if !folder_path.exists() {
400        fs::create_dir_all(folder_path)?;
401
402        let original_file_path = folder_path.join("original_file.txt");
403        fs::write(original_file_path, file_to_mutate)?;
404    }
405
406    Ok(())
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use tempfile::tempdir;
413
414    #[test]
415    fn test_should_skip_line() {
416        // Test basic skip patterns
417        assert!(should_skip_line("// This is a comment", "test.cpp", false).unwrap());
418        assert!(should_skip_line("assert(condition);", "test.cpp", false).unwrap());
419        assert!(should_skip_line("LogPrintf(\"test\");", "test.cpp", false).unwrap());
420        assert!(should_skip_line("LogDebug(\"test\");", "test.cpp", false).unwrap());
421
422        // Test normal lines that shouldn't be skipped
423        assert!(!should_skip_line("int x = 5;", "test.cpp", false).unwrap());
424        assert!(!should_skip_line("return value;", "test.cpp", false).unwrap());
425    }
426
427    #[test]
428    fn test_create_mutation_folder() {
429        let temp_dir = tempdir().unwrap();
430        let folder_path = temp_dir.path().join("test_muts");
431        let folder_name = folder_path.to_str().unwrap();
432
433        create_mutation_folder(folder_name, "test/file.cpp").unwrap();
434
435        assert!(folder_path.exists());
436        assert!(folder_path.join("original_file.txt").exists());
437
438        let content = fs::read_to_string(folder_path.join("original_file.txt")).unwrap();
439        assert_eq!(content, "test/file.cpp");
440    }
441
442    #[test]
443    fn test_write_mutation() {
444        let temp_dir = tempdir().unwrap();
445        std::env::set_current_dir(&temp_dir).unwrap();
446
447        let result = write_mutation("test.cpp", "mutated content", 0, None, None).unwrap();
448        assert_eq!(result, 1);
449
450        let folder_path = Path::new("muts-test-cpp");
451        assert!(folder_path.exists());
452        assert!(folder_path.join("test.mutant.0.cpp").exists());
453
454        let content = fs::read_to_string(folder_path.join("test.mutant.0.cpp")).unwrap();
455        assert_eq!(content, "mutated content");
456    }
457}