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