Skip to main content

gdtest_analysis/
mutate.rs

1//! GDScript mutation testing operators.
2//!
3//! Generates single-point mutations by replacing operators/literals in
4//! GDScript source text. Mutations inside string literals and comments are
5//! skipped via lightweight string-state tracking.
6
7/// One mutation: byte range in the original source → replacement text.
8#[derive(Debug, Clone)]
9pub struct Mutation {
10    /// Byte range in the original source to replace.
11    pub start: usize,
12    pub end: usize,
13    /// What was there before.
14    pub from: &'static str,
15    /// What it becomes.
16    pub to: &'static str,
17    /// 1-based source line number (for reporting).
18    pub line: u32,
19}
20
21impl Mutation {
22    /// Apply this mutation to `source`, returning the mutated bytes.
23    pub fn apply(&self, source: &[u8]) -> Vec<u8> {
24        let mut out = Vec::with_capacity(source.len() + self.to.len());
25        out.extend_from_slice(&source[..self.start]);
26        out.extend_from_slice(self.to.as_bytes());
27        out.extend_from_slice(&source[self.end..]);
28        out
29    }
30
31    /// Human-readable description for reports.
32    pub fn description(&self) -> String {
33        format!("line {}: `{}` → `{}`", self.line, self.from, self.to)
34    }
35}
36
37/// Mutation operator pairs: (from, to). Longer tokens must come before
38/// shorter prefixes so `>=` is matched before `>`.
39static OPERATORS: &[(&str, &str)] = &[
40    (">=", ">"),
41    ("<=", "<"),
42    ("!=", "=="),
43    ("==", "!="),
44    (" and ", " or "),
45    (" or ", " and "),
46    (" + ", " - "),
47    (" - ", " + "),
48    (" * ", " / "),
49    (" / ", " * "),
50    (">", ">="),
51    ("<", "<="),
52    ("true", "false"),
53    ("false", "true"),
54];
55
56/// Find all single-point mutations in `source`. Returns them in source order.
57pub fn find_mutations(source: &[u8]) -> Vec<Mutation> {
58    let text = match std::str::from_utf8(source) {
59        Ok(s) => s,
60        Err(_) => return Vec::new(),
61    };
62
63    let mut mutations: Vec<Mutation> = Vec::new();
64    let mut byte_offset: usize = 0;
65
66    for (line_idx, raw_line) in text.lines().enumerate() {
67        let line_num = (line_idx + 1) as u32;
68
69        // Strip trailing \r if present (CRLF files)
70        let line = raw_line.trim_end_matches('\r');
71
72        // Find the effective end of code (before any # comment, respecting strings)
73        let code_end = comment_start(line);
74        let code = &line[..code_end];
75
76        // Collect candidate mutations for this line, tracking used ranges to
77        // avoid overlapping mutations (e.g. `!=` and `==` both matching `!=`).
78        let mut used: Vec<(usize, usize)> = Vec::new();
79
80        for &(from, to) in OPERATORS {
81            let mut scan = 0usize;
82            while scan < code.len() {
83                let Some(rel) = code[scan..].find(from) else {
84                    break;
85                };
86                let abs = scan + rel;
87                scan = abs + 1; // advance past this position
88
89                // Skip if overlapping an already-emitted mutation.
90                if used.iter().any(|&(s, e)| s < abs + from.len() && e > abs) {
91                    continue;
92                }
93
94                // Skip if inside a string literal.
95                if inside_string(code, abs) {
96                    continue;
97                }
98
99                // Guard single-char `>` / `<` against matching inside `>=`, `<=`, etc.
100                if from == ">" || from == "<" {
101                    let prev = abs.checked_sub(1).map(|i| code.as_bytes()[i]);
102                    let next = code.as_bytes().get(abs + from.len()).copied();
103                    if matches!(prev, Some(b'>' | b'<' | b'=' | b'!')) || matches!(next, Some(b'='))
104                    {
105                        continue;
106                    }
107                }
108
109                // Guard `true` / `false` to match only at word boundaries.
110                if from == "true" || from == "false" {
111                    let prev = abs.checked_sub(1).map(|i| code.as_bytes()[i]);
112                    let next = code.as_bytes().get(abs + from.len()).copied();
113                    let prev_ok = prev.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_');
114                    let next_ok = next.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_');
115                    if !prev_ok || !next_ok {
116                        continue;
117                    }
118                }
119
120                used.push((abs, abs + from.len()));
121                mutations.push(Mutation {
122                    start: byte_offset + abs,
123                    end: byte_offset + abs + from.len(),
124                    from,
125                    to,
126                    line: line_num,
127                });
128                scan = abs + from.len();
129            }
130        }
131
132        byte_offset += raw_line.len() + 1; // +1 for the \n stripped by lines()
133    }
134
135    mutations
136}
137
138/// Return the byte index where a `#` comment begins (outside strings), or
139/// `line.len()` if there is no comment.
140fn comment_start(line: &str) -> usize {
141    let mut in_str: Option<char> = None;
142    for (i, c) in line.char_indices() {
143        match in_str {
144            Some(q) if c == q => in_str = None,
145            None if c == '"' || c == '\'' => in_str = Some(c),
146            None if c == '#' => return i,
147            _ => {}
148        }
149    }
150    line.len()
151}
152
153/// Return true if byte position `pos` in `line` is inside a string literal.
154fn inside_string(line: &str, pos: usize) -> bool {
155    let mut in_str: Option<char> = None;
156    for (i, c) in line.char_indices() {
157        if i >= pos {
158            break;
159        }
160        match in_str {
161            Some(q) if c == q => in_str = None,
162            None if c == '"' || c == '\'' => in_str = Some(c),
163            _ => {}
164        }
165    }
166    in_str.is_some()
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn finds_comparison_mutations() {
175        let src = b"if health > 0:\n    pass\n";
176        let muts = find_mutations(src);
177        assert!(
178            muts.iter()
179                .any(|m| m.from == ">" && m.to == ">=" && m.line == 1),
180            "expected > → >= mutation: {muts:?}"
181        );
182    }
183
184    #[test]
185    fn skips_greater_equal_for_plain_greater() {
186        let src = b"if x >= 0:\n    pass\n";
187        let muts = find_mutations(src);
188        // >= → > is valid, but > → >= should not also appear for the same position
189        let gt_to_gte: Vec<_> = muts
190            .iter()
191            .filter(|m| m.from == ">" && m.to == ">=")
192            .collect();
193        assert!(
194            gt_to_gte.is_empty(),
195            "bare > should not appear inside >=: {gt_to_gte:?}"
196        );
197    }
198
199    #[test]
200    fn skips_operators_in_strings() {
201        let src = b"var s = \"a > b\"\nif x > 0:\n    pass\n";
202        let muts = find_mutations(src);
203        // Only line 2 should have the mutation, not line 1 (inside string)
204        let line1: Vec<_> = muts.iter().filter(|m| m.line == 1).collect();
205        assert!(line1.is_empty(), "no mutations inside strings: {line1:?}");
206        assert!(muts.iter().any(|m| m.line == 2 && m.from == ">"));
207    }
208
209    #[test]
210    fn skips_comments() {
211        let src = b"# if x > 0: pass\nif y > 0:\n    pass\n";
212        let muts = find_mutations(src);
213        let line1: Vec<_> = muts.iter().filter(|m| m.line == 1).collect();
214        assert!(line1.is_empty(), "no mutations in comments: {line1:?}");
215    }
216
217    #[test]
218    fn true_false_mutation_respects_word_boundary() {
219        // "true" inside identifiers like "is_true" or "retrue" must not be mutated.
220        let src = b"var is_true := true\nvar retrue := 1\n";
221        let muts = find_mutations(src);
222        let true_muts: Vec<_> = muts
223            .iter()
224            .filter(|m| m.from == "true" && m.to == "false")
225            .collect();
226        assert_eq!(
227            true_muts.len(),
228            1,
229            "expected exactly 1 true→false: {true_muts:?}"
230        );
231        // The one mutation must be the rvalue on line 1, not inside the identifier.
232        assert_eq!(true_muts[0].line, 1);
233        let col = true_muts[0].start;
234        assert!(
235            col > 10,
236            "mutation should be past the identifier, got byte offset {col}"
237        );
238    }
239
240    #[test]
241    fn apply_mutation_replaces_bytes() {
242        let src = b"if x > 0:\n    pass\n";
243        let muts = find_mutations(src);
244        let gt_mut = muts.iter().find(|m| m.from == ">").unwrap();
245        let mutated = gt_mut.apply(src);
246        let mutated_str = std::str::from_utf8(&mutated).unwrap();
247        assert!(
248            mutated_str.contains(">="),
249            "mutation not applied: {mutated_str}"
250        );
251        assert!(
252            !mutated_str.contains(" > ") || mutated_str.contains(">="),
253            "original not replaced"
254        );
255    }
256}