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 GutMigrationStatus {
10 CandidateForExecution,
11 RewriteIncomplete,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub struct GutMigrationAction {
16 pub line: usize,
17 pub pattern: String,
18 pub replacement: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct GutMigrationIssue {
23 pub line: usize,
24 pub pattern: String,
25 pub message: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct GutMigrationOutput {
30 pub source: Vec<u8>,
31 pub changed: bool,
32 pub status: GutMigrationStatus,
33 pub rewritten: Vec<GutMigrationAction>,
34 pub blocked: Vec<GutMigrationIssue>,
35 pub manual_followups: Vec<GutMigrationIssue>,
36}
37
38pub fn is_gut_test_file(path: &Path) -> Result<bool> {
39 let source = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
40 Ok(is_gut_test_source(&source))
41}
42
43pub fn migrate_gut_file(path: &Path, res_path: &str) -> Result<GutMigrationOutput> {
44 let source = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
45 Ok(migrate_gut_source(&source, res_path))
46}
47
48pub fn is_gut_test_source(source: &[u8]) -> bool {
49 let text = String::from_utf8_lossy(source);
50 text.lines().any(|line| {
51 let trimmed = line.trim_start();
52 trimmed.starts_with("extends GutTest")
53 || trimmed.starts_with("extends \"res://addons/gut/test.gd\"")
54 || trimmed.starts_with("extends 'res://addons/gut/test.gd'")
55 })
56}
57
58pub fn migrate_gut_source(source: &[u8], _res_path: &str) -> GutMigrationOutput {
59 let text = String::from_utf8_lossy(source);
60 let mut rewritten = Vec::new();
61 let mut blocked = Vec::new();
62 let mut manual_followups = Vec::new();
63 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
64 let had_trailing_newline = text.ends_with('\n');
65
66 for (idx, line) in lines.iter_mut().enumerate() {
67 let line_no = idx + 1;
68 let original_line = line.clone();
69 let trimmed = original_line.trim_start();
70 if trimmed.starts_with('#') {
71 continue;
72 }
73
74 if replace_extends(line) && original_line != *line {
75 rewritten.push(GutMigrationAction {
76 line: line_no,
77 pattern: "extends GutTest".to_string(),
78 replacement: "extends GdTest".to_string(),
79 });
80 }
81
82 rewrite_constructor_chain(
83 line,
84 line_no,
85 "partial_double",
86 "partial_mock",
87 &mut rewritten,
88 );
89 rewrite_constructor_chain(line, line_no, "double", "mock", &mut rewritten);
90 rewrite_stub_to_return(line, line_no, &mut rewritten);
91 rewrite_call_name(
92 line,
93 line_no,
94 "yield_for",
95 "wait_physics_seconds",
96 &mut rewritten,
97 );
98 rewrite_call_name(
99 line,
100 line_no,
101 "yield_frames",
102 "wait_physics_frames",
103 &mut rewritten,
104 );
105 rewrite_call_name(
106 line,
107 line_no,
108 "wait_frames",
109 "wait_physics_frames",
110 &mut rewritten,
111 );
112 rewrite_wait_process_frames(line, line_no, &mut rewritten, &mut manual_followups);
113 rewrite_wait_idle_frames(line, line_no, &mut rewritten, &mut manual_followups);
114 rewrite_signal_assertions(line, line_no, &mut rewritten, &mut manual_followups);
115 rewrite_yield_to(line, line_no, &mut rewritten, &mut manual_followups);
116
117 if original_line.contains("watch_signals(") {
118 manual_followups.push(GutMigrationIssue {
119 line: line_no,
120 pattern: "watch_signals(...)".to_string(),
121 message: "watch_signals() is not auto-migrated; expand to explicit gdtest per-signal assertions by hand.".to_string(),
122 });
123 }
124 if original_line.contains("use_parameters(") {
125 blocked.push(GutMigrationIssue {
126 line: line_no,
127 pattern: "use_parameters(...)".to_string(),
128 message:
129 "Parameterized GUT tests need manual restructuring; v1 does not rewrite them."
130 .to_string(),
131 });
132 }
133 if original_line.contains("assert_engine_error(")
134 || original_line.contains("assert_engine_error_count(")
135 {
136 blocked.push(GutMigrationIssue {
137 line: line_no,
138 pattern: "assert_engine_error*".to_string(),
139 message: "Engine error assertions have no safe canonical gdtest rewrite in v1."
140 .to_string(),
141 });
142 }
143 if trimmed.starts_with("func should_skip_script(") {
144 blocked.push(GutMigrationIssue {
145 line: line_no,
146 pattern: "should_skip_script()".to_string(),
147 message: "Script-level skip behavior is not auto-migrated; convert it manually."
148 .to_string(),
149 });
150 }
151 if original_line.contains("partial_double_singleton(")
152 || original_line.contains("double_singleton(")
153 {
154 blocked.push(GutMigrationIssue {
155 line: line_no,
156 pattern: "double_singleton()/partial_double_singleton()".to_string(),
157 message:
158 "Singleton doubles need manual migration; no safe gdtest rewrite is applied."
159 .to_string(),
160 });
161 }
162 if original_line.contains("double(")
163 && !original_line.contains(".new()")
164 && line == &original_line
165 {
166 blocked.push(GutMigrationIssue {
167 line: line_no,
168 pattern: "double(...)".to_string(),
169 message: "Only `double(...).new()` is auto-rewritten in v1. Other double() forms need manual review.".to_string(),
170 });
171 }
172 if original_line.contains("partial_double(")
173 && !original_line.contains(".new()")
174 && line == &original_line
175 {
176 blocked.push(GutMigrationIssue {
177 line: line_no,
178 pattern: "partial_double(...)".to_string(),
179 message: "Only `partial_double(...).new()` is auto-rewritten in v1. Other partial_double() forms need manual review.".to_string(),
180 });
181 }
182 if original_line.contains("stub(")
183 && original_line.contains(".to_")
184 && line == &original_line
185 {
186 manual_followups.push(GutMigrationIssue {
187 line: line_no,
188 pattern: "stub(...).to_*".to_string(),
189 message: "This stub chain was not recognized as a safe direct-value rewrite; convert it manually.".to_string(),
190 });
191 }
192 }
193
194 dedup_issues(&mut blocked);
195 dedup_issues(&mut manual_followups);
196
197 if !blocked.is_empty() || !manual_followups.is_empty() {
198 let mut header: Vec<String> =
199 vec!["# gdtest migrate gut: manual follow-up required".to_string()];
200 for issue in blocked.iter().chain(manual_followups.iter()) {
201 header.push(format!("# line {}: {}", issue.line, issue.message));
202 }
203 header.push(String::new());
204 let mut merged = header;
205 merged.extend(lines);
206 lines = merged;
207 }
208
209 let mut rendered = lines.join("\n");
210 if had_trailing_newline {
211 rendered.push('\n');
212 }
213
214 let changed = rendered.as_bytes() != source;
215 let status = if blocked.is_empty() && manual_followups.is_empty() {
216 GutMigrationStatus::CandidateForExecution
217 } else {
218 GutMigrationStatus::RewriteIncomplete
219 };
220
221 GutMigrationOutput {
222 source: rendered.into_bytes(),
223 changed,
224 status,
225 rewritten,
226 blocked,
227 manual_followups,
228 }
229}
230
231fn replace_extends(line: &mut String) -> bool {
232 let original = line.clone();
233 *line = line.replace("extends GutTest", "extends GdTest");
234 *line = line.replace("extends \"res://addons/gut/test.gd\"", "extends GdTest");
235 *line = line.replace("extends 'res://addons/gut/test.gd'", "extends GdTest");
236 *line != original
237}
238
239fn rewrite_call_name(
240 line: &mut String,
241 line_no: usize,
242 old_name: &str,
243 new_name: &str,
244 rewritten: &mut Vec<GutMigrationAction>,
245) {
246 let mut cursor = 0usize;
247 while let Some((start, end, args)) = find_named_call(line, old_name, cursor) {
248 line.replace_range(start..end, &format!("{new_name}({args})"));
249 rewritten.push(GutMigrationAction {
250 line: line_no,
251 pattern: format!("{old_name}(...)"),
252 replacement: format!("{new_name}(...)"),
253 });
254 cursor = start + new_name.len();
255 }
256}
257
258fn rewrite_wait_process_frames(
259 line: &mut String,
260 line_no: usize,
261 rewritten: &mut Vec<GutMigrationAction>,
262 manual_followups: &mut Vec<GutMigrationIssue>,
263) {
264 rewrite_wait_single_frame(
265 line,
266 line_no,
267 "wait_process_frames",
268 "wait_process_frame",
269 rewritten,
270 manual_followups,
271 );
272}
273
274fn rewrite_wait_idle_frames(
275 line: &mut String,
276 line_no: usize,
277 rewritten: &mut Vec<GutMigrationAction>,
278 manual_followups: &mut Vec<GutMigrationIssue>,
279) {
280 rewrite_wait_single_frame(
281 line,
282 line_no,
283 "wait_idle_frames",
284 "wait_process_frame",
285 rewritten,
286 manual_followups,
287 );
288}
289
290fn rewrite_wait_single_frame(
291 line: &mut String,
292 line_no: usize,
293 old_name: &str,
294 new_name: &str,
295 rewritten: &mut Vec<GutMigrationAction>,
296 manual_followups: &mut Vec<GutMigrationIssue>,
297) {
298 let mut cursor = 0usize;
299 while let Some((start, end, args)) = find_named_call(line, old_name, cursor) {
300 let parsed = split_top_level_args(&args);
301 if parsed.len() == 1 && is_literal_one(parsed[0].as_str()) {
302 line.replace_range(start..end, &format!("{new_name}()"));
303 rewritten.push(GutMigrationAction {
304 line: line_no,
305 pattern: format!("{old_name}(1)"),
306 replacement: format!("{new_name}()"),
307 });
308 cursor = start + new_name.len();
309 } else {
310 manual_followups.push(GutMigrationIssue {
311 line: line_no,
312 pattern: format!("{old_name}(...)"),
313 message: format!("{old_name} only auto-rewrites when waiting for exactly one frame; larger counts need manual expansion."),
314 });
315 cursor = end;
316 }
317 }
318}
319
320fn rewrite_constructor_chain(
321 line: &mut String,
322 line_no: usize,
323 old_name: &str,
324 new_name: &str,
325 rewritten: &mut Vec<GutMigrationAction>,
326) {
327 let mut cursor = 0usize;
328 while let Some((call_start, call_end, args)) = find_named_call(line, old_name, cursor) {
329 let suffix = &line[call_end..];
330 let Some(new_end) = parse_dot_new_suffix(suffix).map(|offset| call_end + offset) else {
331 cursor = call_end;
332 continue;
333 };
334 line.replace_range(call_start..new_end, &format!("{new_name}({args})"));
335 rewritten.push(GutMigrationAction {
336 line: line_no,
337 pattern: format!("{old_name}(...).new()"),
338 replacement: format!("{new_name}(...)"),
339 });
340 cursor = call_start + new_name.len();
341 }
342}
343
344fn rewrite_stub_to_return(
345 line: &mut String,
346 line_no: usize,
347 rewritten: &mut Vec<GutMigrationAction>,
348) {
349 let mut cursor = 0usize;
350 while let Some((call_start, call_end, args)) = find_named_call(line, "stub", cursor) {
351 let suffix = &line[call_end..];
352 let Some((suffix_end, value)) = parse_stub_return_suffix(suffix) else {
353 cursor = call_end;
354 continue;
355 };
356 let stub_args = split_top_level_args(&args);
357 if stub_args.len() != 2 {
358 cursor = call_end;
359 continue;
360 }
361 line.replace_range(
362 call_start..(call_end + suffix_end),
363 &format!("stub({}, {}, {})", stub_args[0], stub_args[1], value.trim()),
364 );
365 rewritten.push(GutMigrationAction {
366 line: line_no,
367 pattern: "stub(...).to_return(...)".to_string(),
368 replacement: "stub(..., value)".to_string(),
369 });
370 cursor = call_start + 4;
371 }
372}
373
374fn rewrite_signal_assertions(
375 line: &mut String,
376 line_no: usize,
377 rewritten: &mut Vec<GutMigrationAction>,
378 manual_followups: &mut Vec<GutMigrationIssue>,
379) {
380 let mappings = [
381 ("assert_signal_emitted", "assert_signal", 2usize, false),
382 (
383 "assert_signal_not_emitted",
384 "assert_no_signal",
385 2usize,
386 false,
387 ),
388 (
389 "assert_signal_emit_count",
390 "assert_signal_count",
391 3usize,
392 false,
393 ),
394 (
395 "assert_signal_emitted_with_parameters",
396 "assert_signal_with_args",
397 3usize,
398 true,
399 ),
400 ];
401 for (old_name, new_name, required_args, block_extra) in mappings {
402 let mut cursor = 0usize;
403 while let Some((start, end, args)) = find_named_call(line, old_name, cursor) {
404 let parsed = split_top_level_args(&args);
405 let uses_signal_ref = parsed
406 .first()
407 .map(|arg| !looks_like_signal_object_form(arg))
408 .unwrap_or(false);
409 if uses_signal_ref {
410 manual_followups.push(GutMigrationIssue {
411 line: line_no,
412 pattern: format!("{old_name}(signal_ref, ...)"),
413 message: format!("{old_name} only auto-rewrites for `(object, \"signal\")` calls; signal-reference overloads need manual conversion."),
414 });
415 cursor = end;
416 continue;
417 }
418 if parsed.len() < required_args
419 || (block_extra && parsed.len() != required_args)
420 || (!block_extra && parsed.len() > required_args)
421 {
422 manual_followups.push(GutMigrationIssue {
423 line: line_no,
424 pattern: format!("{old_name}(...)"),
425 message: format!("{old_name} had optional arguments or an unsupported shape; convert it manually."),
426 });
427 cursor = end;
428 continue;
429 }
430 line.replace_range(start..end, &format!("{new_name}({})", parsed.join(", ")));
431 rewritten.push(GutMigrationAction {
432 line: line_no,
433 pattern: format!("{old_name}(...)"),
434 replacement: format!("{new_name}(...)"),
435 });
436 cursor = start + new_name.len();
437 }
438 }
439}
440
441fn rewrite_yield_to(
442 line: &mut String,
443 line_no: usize,
444 rewritten: &mut Vec<GutMigrationAction>,
445 manual_followups: &mut Vec<GutMigrationIssue>,
446) {
447 let mut cursor = 0usize;
448 while let Some((start, end, args)) = find_named_call(line, "yield_to", cursor) {
449 let parsed = split_top_level_args(&args);
450 if parsed.len() != 3
451 || !looks_like_signal_object_form(&parsed[0])
452 || !looks_like_string_literal(&parsed[1])
453 {
454 manual_followups.push(GutMigrationIssue {
455 line: line_no,
456 pattern: "yield_to(...)".to_string(),
457 message: "yield_to() only auto-rewrites for `(object, \"signal\", timeout_seconds)` calls without a message.".to_string(),
458 });
459 cursor = end;
460 continue;
461 }
462 let timeout_expr = format!("int(ceil(({}) * 1000.0))", parsed[2].trim());
463 line.replace_range(
464 start..end,
465 &format!(
466 "assert_signal({}, {}, {})",
467 parsed[0], parsed[1], timeout_expr
468 ),
469 );
470 rewritten.push(GutMigrationAction {
471 line: line_no,
472 pattern: "yield_to(obj, \"signal\", timeout_seconds)".to_string(),
473 replacement: "assert_signal(obj, \"signal\", timeout_ms)".to_string(),
474 });
475 cursor = start + "assert_signal".len();
476 }
477}
478
479fn find_named_call(line: &str, name: &str, from: usize) -> Option<(usize, usize, String)> {
480 let needle = format!("{name}(");
481 let mut search = from;
482 while let Some(found) = line[search..].find(&needle) {
483 let start = search + found;
484 if start > 0 {
485 let prev = line[..start].chars().next_back().unwrap_or(' ');
486 if prev.is_ascii_alphanumeric() || prev == '_' || prev == '.' {
487 search = start + needle.len();
488 continue;
489 }
490 }
491 let open = start + name.len();
492 let end = find_matching_paren(line, open)?;
493 return Some((start, end + 1, line[(open + 1)..end].to_string()));
494 }
495 None
496}
497
498fn find_matching_paren(text: &str, open_idx: usize) -> Option<usize> {
499 let bytes = text.as_bytes();
500 if bytes.get(open_idx) != Some(&b'(') {
501 return None;
502 }
503 let mut depth = 0usize;
504 let mut in_string: Option<u8> = None;
505 let mut escaped = false;
506 for (idx, byte) in bytes.iter().enumerate().skip(open_idx) {
507 if let Some(quote) = in_string {
508 if escaped {
509 escaped = false;
510 continue;
511 }
512 if *byte == b'\\' {
513 escaped = true;
514 continue;
515 }
516 if *byte == quote {
517 in_string = None;
518 }
519 continue;
520 }
521 match *byte {
522 b'"' | b'\'' => in_string = Some(*byte),
523 b'(' | b'[' | b'{' => depth += 1,
524 b')' => {
525 depth = depth.saturating_sub(1);
526 if depth == 0 {
527 return Some(idx);
528 }
529 }
530 b']' | b'}' => depth = depth.saturating_sub(1),
531 _ => {}
532 }
533 }
534 None
535}
536
537fn split_top_level_args(args: &str) -> Vec<String> {
538 let mut parts = Vec::new();
539 let mut current = String::new();
540 let mut paren = 0usize;
541 let mut bracket = 0usize;
542 let mut brace = 0usize;
543 let mut in_string: Option<char> = None;
544 let mut escaped = false;
545 for ch in args.chars() {
546 if let Some(quote) = in_string {
547 current.push(ch);
548 if escaped {
549 escaped = false;
550 } else if ch == '\\' {
551 escaped = true;
552 } else if ch == quote {
553 in_string = None;
554 }
555 continue;
556 }
557 match ch {
558 '"' | '\'' => {
559 in_string = Some(ch);
560 current.push(ch);
561 }
562 '(' => {
563 paren += 1;
564 current.push(ch);
565 }
566 ')' => {
567 paren = paren.saturating_sub(1);
568 current.push(ch);
569 }
570 '[' => {
571 bracket += 1;
572 current.push(ch);
573 }
574 ']' => {
575 bracket = bracket.saturating_sub(1);
576 current.push(ch);
577 }
578 '{' => {
579 brace += 1;
580 current.push(ch);
581 }
582 '}' => {
583 brace = brace.saturating_sub(1);
584 current.push(ch);
585 }
586 ',' if paren == 0 && bracket == 0 && brace == 0 => {
587 let part = current.trim();
588 if !part.is_empty() {
589 parts.push(part.to_string());
590 }
591 current.clear();
592 }
593 _ => current.push(ch),
594 }
595 }
596 let part = current.trim();
597 if !part.is_empty() {
598 parts.push(part.to_string());
599 }
600 parts
601}
602
603fn parse_dot_new_suffix(suffix: &str) -> Option<usize> {
604 let re = Regex::new(r"^\s*\.new\(\s*\)").ok()?;
605 re.find(suffix).map(|m| m.end())
606}
607
608fn parse_stub_return_suffix(suffix: &str) -> Option<(usize, String)> {
609 let re = Regex::new(r"^\s*\.to_return(?:_const)?\(").ok()?;
610 let m = re.find(suffix)?;
611 let open_idx = m.end() - 1;
612 let close_idx = find_matching_paren(suffix, open_idx)?;
613 Some((close_idx + 1, suffix[(open_idx + 1)..close_idx].to_string()))
614}
615
616fn looks_like_signal_object_form(arg: &str) -> bool {
617 let trimmed = arg.trim();
618 !trimmed.is_empty() && !trimmed.contains('.') && !trimmed.starts_with("Signal(")
619}
620
621fn looks_like_string_literal(arg: &str) -> bool {
622 let trimmed = arg.trim();
623 (trimmed.starts_with('"') && trimmed.ends_with('"'))
624 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
625}
626
627fn is_literal_one(arg: &str) -> bool {
628 matches!(arg.trim(), "1" | "1.0")
629}
630
631fn dedup_issues(issues: &mut Vec<GutMigrationIssue>) {
632 issues.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.pattern.cmp(&b.pattern)));
633 issues.dedup_by(|a, b| a.line == b.line && a.pattern == b.pattern && a.message == b.message);
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 #[test]
641 fn rewrites_basic_gut_extends_and_waits() {
642 let src = br#"extends GutTest
643
644func test_waits():
645 await wait_frames(2)
646 await yield_for(0.5)
647"#;
648 let out = migrate_gut_source(src, "res://test/test_waits.gd");
649 let text = String::from_utf8(out.source).unwrap();
650 assert!(text.contains("extends GdTest"));
651 assert!(text.contains("wait_physics_frames(2)"));
652 assert!(text.contains("wait_physics_seconds(0.5)"));
653 assert_eq!(out.status, GutMigrationStatus::CandidateForExecution);
654 }
655
656 #[test]
657 fn rewrites_signal_assertions_and_doubles() {
658 let src = br#"extends GutTest
659
660func test_signal_and_mock():
661 var m = double(HealthComponent).new()
662 stub(m, "get_hp").to_return(42)
663 assert_signal_emitted(self, "done")
664 assert_signal_emit_count(self, "done", 2)
665 assert_signal_emitted_with_parameters(self, "done", [42])
666"#;
667 let out = migrate_gut_source(src, "res://test/test_signal.gd");
668 let text = String::from_utf8(out.source).unwrap();
669 assert!(text.contains("var m = mock(HealthComponent)"));
670 assert!(text.contains("stub(m, \"get_hp\", 42)"));
671 assert!(text.contains("assert_signal(self, \"done\")"));
672 assert!(text.contains("assert_signal_count(self, \"done\", 2)"));
673 assert!(text.contains("assert_signal_with_args(self, \"done\", [42])"));
674 assert_eq!(out.status, GutMigrationStatus::CandidateForExecution);
675 }
676
677 #[test]
678 fn flags_unsupported_patterns() {
679 let src = br#"extends GutTest
680
681func should_skip_script():
682 return true
683
684func test_unsupported():
685 watch_signals(self)
686 use_parameters([1, 2, 3])
687 assert_engine_error("boom")
688 assert_signal_emitted(self.done)
689 await wait_process_frames(2)
690"#;
691 let out = migrate_gut_source(src, "res://test/test_blocked.gd");
692 let text = String::from_utf8(out.source).unwrap();
693 assert!(text.starts_with("# gdtest migrate gut: manual follow-up required"));
694 assert_eq!(out.status, GutMigrationStatus::RewriteIncomplete);
695 assert!(
696 out.blocked
697 .iter()
698 .any(|i| i.pattern == "should_skip_script()")
699 );
700 assert!(
701 out.blocked
702 .iter()
703 .any(|i| i.pattern == "use_parameters(...)")
704 );
705 assert!(
706 out.blocked
707 .iter()
708 .any(|i| i.pattern == "assert_engine_error*")
709 );
710 assert!(
711 out.manual_followups
712 .iter()
713 .any(|i| i.pattern == "watch_signals(...)")
714 );
715 assert!(
716 out.manual_followups
717 .iter()
718 .any(|i| i.pattern == "assert_signal_emitted(signal_ref, ...)")
719 );
720 assert!(
721 out.manual_followups
722 .iter()
723 .any(|i| i.pattern == "wait_process_frames(...)")
724 );
725 }
726
727 #[test]
728 fn rewrites_single_frame_wait_process_variants() {
729 let src = br#"extends GutTest
730
731func test_one_frame():
732 await wait_process_frames(1)
733 await wait_idle_frames(1)
734"#;
735 let out = migrate_gut_source(src, "res://test/test_process_frame.gd");
736 let text = String::from_utf8(out.source).unwrap();
737 assert!(text.contains("await wait_process_frame()"));
738 assert_eq!(text.matches("wait_process_frame()").count(), 2);
739 }
740}