Skip to main content

gdtest_analysis/
gdunit4_migrate.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use regex::Regex;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum GdUnit4MigrationStatus {
10    CandidateForExecution,
11    RewriteIncomplete,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub struct GdUnit4MigrationAction {
16    pub line: usize,
17    pub pattern: String,
18    pub replacement: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct GdUnit4MigrationIssue {
23    pub line: usize,
24    pub pattern: String,
25    pub message: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct GdUnit4MigrationOutput {
30    pub source: Vec<u8>,
31    pub changed: bool,
32    pub status: GdUnit4MigrationStatus,
33    pub rewritten: Vec<GdUnit4MigrationAction>,
34    pub blocked: Vec<GdUnit4MigrationIssue>,
35    pub manual_followups: Vec<GdUnit4MigrationIssue>,
36}
37
38pub fn is_gdunit4_test_file(path: &Path) -> Result<bool> {
39    let source = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
40    Ok(is_gdunit4_test_source(&source))
41}
42
43pub fn migrate_gdunit4_file(path: &Path, res_path: &str) -> Result<GdUnit4MigrationOutput> {
44    let source = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
45    Ok(migrate_gdunit4_source(&source, res_path))
46}
47
48pub fn is_gdunit4_test_source(source: &[u8]) -> bool {
49    let text = String::from_utf8_lossy(source);
50    text.lines()
51        .any(|line| line.trim_start().starts_with("extends GdUnitTestSuite"))
52}
53
54pub fn migrate_gdunit4_source(source: &[u8], _res_path: &str) -> GdUnit4MigrationOutput {
55    let text = String::from_utf8_lossy(source);
56    let mut rewritten = Vec::new();
57    let mut blocked = Vec::new();
58    let mut manual_followups = Vec::new();
59    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
60    let had_trailing_newline = text.ends_with('\n');
61
62    for (idx, line) in lines.iter_mut().enumerate() {
63        let line_no = idx + 1;
64        let original_line = line.clone();
65        let trimmed = original_line.trim_start();
66        if trimmed.starts_with('#') {
67            continue;
68        }
69
70        if replace_extends(line) && original_line != *line {
71            rewritten.push(GdUnit4MigrationAction {
72                line: line_no,
73                pattern: "extends GdUnitTestSuite".to_string(),
74                replacement: "extends GdTestCompatGdUnit".to_string(),
75            });
76        }
77
78        if remove_class_name(line) && original_line != *line {
79            rewritten.push(GdUnit4MigrationAction {
80                line: line_no,
81                pattern: "class_name ...".to_string(),
82                replacement: "(removed)".to_string(),
83            });
84        }
85
86        rewrite_call_name(
87            line,
88            line_no,
89            "simulate_action_release",
90            "simulate_action_released",
91            &mut rewritten,
92        );
93        rewrite_call_name(
94            line,
95            line_no,
96            "simulate_mouse_button_release",
97            "simulate_mouse_button_released",
98            &mut rewritten,
99        );
100        rewrite_call_name(
101            line,
102            line_no,
103            "simulate_key_release",
104            "simulate_key_released",
105            &mut rewritten,
106        );
107        rewrite_call_name(
108            line,
109            line_no,
110            "await_input_processed",
111            "wait_process_frame",
112            &mut rewritten,
113        );
114        rewrite_scene_runner(line, line_no, &mut rewritten, &mut manual_followups);
115        rewrite_verify_chain(line, line_no, &mut rewritten, &mut manual_followups);
116
117        if original_line.contains("clean_temp_dir(") {
118            manual_followups.push(GdUnit4MigrationIssue {
119                line: line_no,
120                pattern: "clean_temp_dir(...)".to_string(),
121                message: "clean_temp_dir() has no safe generic rewrite in v1; convert it manually to delete_temp_dir(name) when the temp dir name is explicit.".to_string(),
122            });
123        }
124        if original_line.contains("monitor_signals(") {
125            manual_followups.push(GdUnit4MigrationIssue {
126                line: line_no,
127                pattern: "monitor_signals(...)".to_string(),
128                message: "monitor_signals() is not auto-migrated; expand it to explicit per-signal gdtest watchers by hand.".to_string(),
129            });
130        }
131        if original_line.contains("verify_signal(") {
132            manual_followups.push(GdUnit4MigrationIssue {
133                line: line_no,
134                pattern: "verify_signal(...)".to_string(),
135                message: "verify_signal() is not auto-migrated in v1; inspect the watcher state manually.".to_string(),
136            });
137        }
138        if original_line.contains("@GdUnitTestParameters") {
139            blocked.push(GdUnit4MigrationIssue {
140                line: line_no,
141                pattern: "@GdUnitTestParameters".to_string(),
142                message: "Annotation-driven parameterized tests are not auto-migrated in v1."
143                    .to_string(),
144            });
145        }
146        if original_line.contains("GdUnitTuple") || original_line.contains("tuple(") {
147            blocked.push(GdUnit4MigrationIssue {
148                line: line_no,
149                pattern: "GdUnitTuple / tuple(...)".to_string(),
150                message: "GdUnitTuple-based parameterized tests are not auto-migrated in v1."
151                    .to_string(),
152            });
153        }
154        if original_line.contains("@GdUnitMock") {
155            blocked.push(GdUnit4MigrationIssue {
156                line: line_no,
157                pattern: "@GdUnitMock".to_string(),
158                message: "Annotation-driven mock injection is not auto-migrated in v1.".to_string(),
159            });
160        }
161        if original_line.contains("RETURN_DEEP_STUB") {
162            blocked.push(GdUnit4MigrationIssue {
163                line: line_no,
164                pattern: "RETURN_DEEP_STUB".to_string(),
165                message: "Deep stubs have no gdtest equivalent in v1.".to_string(),
166            });
167        }
168        if original_line.contains("verify_no_more_interactions(") {
169            blocked.push(GdUnit4MigrationIssue {
170                line: line_no,
171                pattern: "verify_no_more_interactions(...)".to_string(),
172                message: "verify_no_more_interactions() has no gdtest equivalent in v1."
173                    .to_string(),
174            });
175        }
176        if original_line.contains("reset(") {
177            blocked.push(GdUnit4MigrationIssue {
178                line: line_no,
179                pattern: "reset(...)".to_string(),
180                message: "reset(mock) has no gdtest equivalent in v1.".to_string(),
181            });
182        }
183        if original_line.contains("InputFactory.") {
184            blocked.push(GdUnit4MigrationIssue {
185                line: line_no,
186                pattern: "InputFactory.*".to_string(),
187                message: "InputFactory helpers are not auto-migrated in v1.".to_string(),
188            });
189        }
190        if original_line.contains("simulate_mouse_move_relative(") {
191            blocked.push(GdUnit4MigrationIssue {
192                line: line_no,
193                pattern: "simulate_mouse_move_relative(...)".to_string(),
194                message: "Relative mouse movement has no gdtest equivalent in v1.".to_string(),
195            });
196        }
197        if original_line.contains("simulate_screen_touch_") {
198            blocked.push(GdUnit4MigrationIssue {
199                line: line_no,
200                pattern: "simulate_screen_touch_*".to_string(),
201                message: "Touch simulation helpers have no gdtest equivalent in v1.".to_string(),
202            });
203        }
204        if original_line.contains("override_failure_message(") {
205            blocked.push(GdUnit4MigrationIssue {
206                line: line_no,
207                pattern: "override_failure_message(...)".to_string(),
208                message: "Fluent failure-message overrides are not supported by the compat layer."
209                    .to_string(),
210            });
211        }
212        if original_line.contains("append_failure_message(") {
213            blocked.push(GdUnit4MigrationIssue {
214                line: line_no,
215                pattern: "append_failure_message(...)".to_string(),
216                message: "Fluent failure-message appends are not supported by the compat layer."
217                    .to_string(),
218            });
219        }
220        if original_line.contains(".matches(") && original_line.contains("assert_str(") {
221            blocked.push(GdUnit4MigrationIssue {
222                line: line_no,
223                pattern: "assert_str(...).matches(...)".to_string(),
224                message: "String regex assertions have no direct gdtest rewrite in v1.".to_string(),
225            });
226        }
227        if original_line.contains(".contains_same(") && original_line.contains("assert_array(") {
228            blocked.push(GdUnit4MigrationIssue {
229                line: line_no,
230                pattern: "assert_array(...).contains_same(...)".to_string(),
231                message:
232                    "Reference-identity collection assertions have no gdtest equivalent in v1."
233                        .to_string(),
234            });
235        }
236        if (original_line.contains(".extract(") || original_line.contains(".extractv("))
237            && original_line.contains("assert_array(")
238        {
239            blocked.push(GdUnit4MigrationIssue {
240                line: line_no,
241                pattern: "assert_array(...).extract(...)".to_string(),
242                message: "Extractor helpers are not auto-migrated in v1.".to_string(),
243            });
244        }
245    }
246
247    dedup_issues(&mut blocked);
248    dedup_issues(&mut manual_followups);
249
250    if !blocked.is_empty() || !manual_followups.is_empty() {
251        let mut header: Vec<String> =
252            vec!["# gdtest migrate gdunit4: manual follow-up required".to_string()];
253        for issue in blocked.iter().chain(manual_followups.iter()) {
254            header.push(format!("# line {}: {}", issue.line, issue.message));
255        }
256        header.push(String::new());
257        let mut merged = header;
258        merged.extend(lines);
259        lines = merged;
260    }
261
262    let mut rendered = lines.join("\n");
263    if had_trailing_newline {
264        rendered.push('\n');
265    }
266
267    let changed = rendered.as_bytes() != source;
268    let status = if blocked.is_empty() && manual_followups.is_empty() {
269        GdUnit4MigrationStatus::CandidateForExecution
270    } else {
271        GdUnit4MigrationStatus::RewriteIncomplete
272    };
273
274    GdUnit4MigrationOutput {
275        source: rendered.into_bytes(),
276        changed,
277        status,
278        rewritten,
279        blocked,
280        manual_followups,
281    }
282}
283
284fn replace_extends(line: &mut String) -> bool {
285    let original = line.clone();
286    *line = line.replace("extends GdUnitTestSuite", "extends GdTestCompatGdUnit");
287    *line != original
288}
289
290fn remove_class_name(line: &mut String) -> bool {
291    let trimmed = line.trim_start();
292    if !trimmed.starts_with("class_name ") {
293        return false;
294    }
295    line.clear();
296    true
297}
298
299fn rewrite_call_name(
300    line: &mut String,
301    line_no: usize,
302    old_name: &str,
303    new_name: &str,
304    rewritten: &mut Vec<GdUnit4MigrationAction>,
305) {
306    let mut cursor = 0usize;
307    while let Some((start, end, args)) = find_named_call(line, old_name, cursor) {
308        line.replace_range(start..end, &format!("{new_name}({args})"));
309        rewritten.push(GdUnit4MigrationAction {
310            line: line_no,
311            pattern: format!("{old_name}(...)"),
312            replacement: format!("{new_name}(...)"),
313        });
314        cursor = start + new_name.len();
315    }
316}
317
318fn rewrite_scene_runner(
319    line: &mut String,
320    line_no: usize,
321    rewritten: &mut Vec<GdUnit4MigrationAction>,
322    manual_followups: &mut Vec<GdUnit4MigrationIssue>,
323) {
324    let mut cursor = 0usize;
325    while let Some((start, end, args)) = find_named_call(line, "scene_runner", cursor) {
326        let parsed = split_top_level_args(&args);
327        if parsed.len() != 1 {
328            manual_followups.push(GdUnit4MigrationIssue {
329                line: line_no,
330                pattern: "scene_runner(...)".to_string(),
331                message:
332                    "scene_runner() only auto-rewrites for a single scene path string argument."
333                        .to_string(),
334            });
335            cursor = end;
336            continue;
337        }
338        let arg = parsed[0].trim();
339        if looks_like_string_literal(arg) {
340            line.replace_range(start..end, &format!("scene_runner(load({arg}))"));
341            rewritten.push(GdUnit4MigrationAction {
342                line: line_no,
343                pattern: "scene_runner(\"res://...\")".to_string(),
344                replacement: "scene_runner(load(\"res://...\"))".to_string(),
345            });
346            cursor = start + "scene_runner".len();
347        } else {
348            manual_followups.push(GdUnit4MigrationIssue {
349                line: line_no,
350                pattern: "scene_runner(...)".to_string(),
351                message:
352                    "scene_runner() only auto-rewrites when the input is a string literal path."
353                        .to_string(),
354            });
355            cursor = end;
356        }
357    }
358}
359
360fn rewrite_verify_chain(
361    line: &mut String,
362    line_no: usize,
363    rewritten: &mut Vec<GdUnit4MigrationAction>,
364    manual_followups: &mut Vec<GdUnit4MigrationIssue>,
365) {
366    let re = Regex::new(
367        r#"verify\s*\(\s*(?P<target>[^,\)]+?)\s*(?:,\s*(?P<count>[^,\)]+?)\s*)?\)\s*\.\s*(?P<method>[A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)"#,
368    )
369    .expect("valid verify regex");
370    let original = line.clone();
371    let mut changed = false;
372    let replaced = re.replace_all(&original, |caps: &regex::Captures<'_>| {
373        changed = true;
374        let target = caps.name("target").unwrap().as_str().trim();
375        let method = caps.name("method").unwrap().as_str().trim();
376        if let Some(count) = caps.name("count") {
377            rewritten.push(GdUnit4MigrationAction {
378                line: line_no,
379                pattern: "verify(mock, n).method()".to_string(),
380                replacement: "assert_call_count(mock, \"method\", n)".to_string(),
381            });
382            format!(
383                "assert_call_count({}, \"{}\", {})",
384                target,
385                method,
386                count.as_str().trim()
387            )
388        } else {
389            rewritten.push(GdUnit4MigrationAction {
390                line: line_no,
391                pattern: "verify(mock).method()".to_string(),
392                replacement: "assert_called(mock, \"method\")".to_string(),
393            });
394            format!("assert_called({}, \"{}\")", target, method)
395        }
396    });
397    if changed {
398        *line = replaced.into_owned();
399        return;
400    }
401    if original.contains("verify(") {
402        manual_followups.push(GdUnit4MigrationIssue {
403            line: line_no,
404            pattern: "verify(...)".to_string(),
405            message: "verify() only auto-rewrites for `verify(mock).method()` and `verify(mock, n).method()` with no call arguments.".to_string(),
406        });
407    }
408}
409
410fn find_named_call(line: &str, name: &str, from: usize) -> Option<(usize, usize, String)> {
411    let needle = format!("{name}(");
412    let mut search = from;
413    while let Some(found) = line[search..].find(&needle) {
414        let start = search + found;
415        if start > 0 {
416            let prev = line[..start].chars().next_back().unwrap_or(' ');
417            if prev.is_ascii_alphanumeric() || prev == '_' || prev == '.' {
418                search = start + needle.len();
419                continue;
420            }
421        }
422        let open = start + name.len();
423        let end = find_matching_paren(line, open)?;
424        return Some((start, end + 1, line[(open + 1)..end].to_string()));
425    }
426    None
427}
428
429fn find_matching_paren(text: &str, open_idx: usize) -> Option<usize> {
430    let bytes = text.as_bytes();
431    if bytes.get(open_idx) != Some(&b'(') {
432        return None;
433    }
434    let mut depth = 0usize;
435    let mut in_string: Option<u8> = None;
436    let mut escaped = false;
437    for (idx, byte) in bytes.iter().enumerate().skip(open_idx) {
438        if let Some(quote) = in_string {
439            if escaped {
440                escaped = false;
441                continue;
442            }
443            if *byte == b'\\' {
444                escaped = true;
445                continue;
446            }
447            if *byte == quote {
448                in_string = None;
449            }
450            continue;
451        }
452        match *byte {
453            b'"' | b'\'' => in_string = Some(*byte),
454            b'(' | b'[' | b'{' => depth += 1,
455            b')' => {
456                depth = depth.saturating_sub(1);
457                if depth == 0 {
458                    return Some(idx);
459                }
460            }
461            b']' | b'}' => depth = depth.saturating_sub(1),
462            _ => {}
463        }
464    }
465    None
466}
467
468fn split_top_level_args(args: &str) -> Vec<String> {
469    let mut parts = Vec::new();
470    let mut current = String::new();
471    let mut paren = 0usize;
472    let mut bracket = 0usize;
473    let mut brace = 0usize;
474    let mut in_string: Option<char> = None;
475    let mut escaped = false;
476    for ch in args.chars() {
477        if let Some(quote) = in_string {
478            current.push(ch);
479            if escaped {
480                escaped = false;
481            } else if ch == '\\' {
482                escaped = true;
483            } else if ch == quote {
484                in_string = None;
485            }
486            continue;
487        }
488        match ch {
489            '"' | '\'' => {
490                in_string = Some(ch);
491                current.push(ch);
492            }
493            '(' => {
494                paren += 1;
495                current.push(ch);
496            }
497            ')' => {
498                paren = paren.saturating_sub(1);
499                current.push(ch);
500            }
501            '[' => {
502                bracket += 1;
503                current.push(ch);
504            }
505            ']' => {
506                bracket = bracket.saturating_sub(1);
507                current.push(ch);
508            }
509            '{' => {
510                brace += 1;
511                current.push(ch);
512            }
513            '}' => {
514                brace = brace.saturating_sub(1);
515                current.push(ch);
516            }
517            ',' if paren == 0 && bracket == 0 && brace == 0 => {
518                let part = current.trim();
519                if !part.is_empty() {
520                    parts.push(part.to_string());
521                }
522                current.clear();
523            }
524            _ => current.push(ch),
525        }
526    }
527    let part = current.trim();
528    if !part.is_empty() {
529        parts.push(part.to_string());
530    }
531    parts
532}
533
534fn looks_like_string_literal(arg: &str) -> bool {
535    let trimmed = arg.trim();
536    (trimmed.starts_with('"') && trimmed.ends_with('"'))
537        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
538}
539
540fn dedup_issues(issues: &mut Vec<GdUnit4MigrationIssue>) {
541    issues.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.pattern.cmp(&b.pattern)));
542    issues.dedup_by(|a, b| a.line == b.line && a.pattern == b.pattern && a.message == b.message);
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn rewrites_basic_gdunit4_suite() {
551        let src = br#"class_name DamageSuite
552extends GdUnitTestSuite
553
554func test_scene() -> void:
555    verify(mock_ai, 2).fire()
556    verify(mock_ai).rest()
557    var runner := scene_runner("res://ui/panel.tscn")
558    simulate_action_release("jump")
559    simulate_mouse_button_release(MOUSE_BUTTON_LEFT)
560    simulate_key_release(KEY_A)
561    await await_input_processed()
562"#;
563        let out = migrate_gdunit4_source(src, "res://test/test_damage.gd");
564        let text = String::from_utf8(out.source).unwrap();
565        assert!(!text.contains("class_name DamageSuite"));
566        assert!(text.contains("extends GdTestCompatGdUnit"));
567        assert!(text.contains("assert_call_count(mock_ai, \"fire\", 2)"));
568        assert!(text.contains("assert_called(mock_ai, \"rest\")"));
569        assert!(text.contains("scene_runner(load(\"res://ui/panel.tscn\"))"));
570        assert!(text.contains("simulate_action_released(\"jump\")"));
571        assert!(text.contains("simulate_mouse_button_released(MOUSE_BUTTON_LEFT)"));
572        assert!(text.contains("simulate_key_released(KEY_A)"));
573        assert!(text.contains("await wait_process_frame()"));
574        assert_eq!(out.status, GdUnit4MigrationStatus::CandidateForExecution);
575    }
576
577    #[test]
578    fn flags_unsupported_patterns() {
579        let src = br#"extends GdUnitTestSuite
580
581@GdUnitTestParameters([
582    tuple("a", "b")
583])
584func test_params() -> void:
585    var m = mock(MyType, RETURN_DEEP_STUB)
586    monitor_signals(self)
587    verify_signal(self)
588    clean_temp_dir()
589    InputFactory.simulate_key_pressed(KEY_A)
590    verify_no_more_interactions(m)
591    reset(m)
592    override_failure_message("boom")
593"#;
594        let out = migrate_gdunit4_source(src, "res://test/test_blocked.gd");
595        let text = String::from_utf8(out.source).unwrap();
596        assert!(text.starts_with("# gdtest migrate gdunit4: manual follow-up required"));
597        assert_eq!(out.status, GdUnit4MigrationStatus::RewriteIncomplete);
598        assert!(
599            out.blocked
600                .iter()
601                .any(|i| i.pattern == "@GdUnitTestParameters")
602        );
603        assert!(
604            out.blocked
605                .iter()
606                .any(|i| i.pattern == "GdUnitTuple / tuple(...)")
607        );
608        assert!(out.blocked.iter().any(|i| i.pattern == "RETURN_DEEP_STUB"));
609        assert!(
610            out.blocked
611                .iter()
612                .any(|i| i.pattern == "verify_no_more_interactions(...)")
613        );
614        assert!(out.blocked.iter().any(|i| i.pattern == "reset(...)"));
615        assert!(out.blocked.iter().any(|i| i.pattern == "InputFactory.*"));
616        assert!(
617            out.manual_followups
618                .iter()
619                .any(|i| i.pattern == "monitor_signals(...)")
620        );
621        assert!(
622            out.manual_followups
623                .iter()
624                .any(|i| i.pattern == "verify_signal(...)")
625        );
626        assert!(
627            out.manual_followups
628                .iter()
629                .any(|i| i.pattern == "clean_temp_dir(...)")
630        );
631    }
632
633    #[test]
634    fn detects_gdunit4_test_source() {
635        assert!(is_gdunit4_test_source(
636            br#"extends GdUnitTestSuite
637
638func test_foo() -> void:
639    pass
640"#
641        ));
642        assert!(!is_gdunit4_test_source(
643            br#"extends GdTest
644
645func test_foo() -> void:
646    pass
647"#
648        ));
649    }
650}