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: ®ex::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}