1pub const DEFAULT_PATTERNS: &[&str] = &["test/**/*.gd", "**/*.test.gd"];
25
26pub mod coverage;
27pub mod deps;
28pub mod gdunit4_migrate;
29pub mod gut_migrate;
30pub mod mutate;
31pub mod rewrite;
32pub use gdunit4_migrate::{
33 GdUnit4MigrationAction, GdUnit4MigrationIssue, GdUnit4MigrationOutput, GdUnit4MigrationStatus,
34 is_gdunit4_test_file, is_gdunit4_test_source, migrate_gdunit4_file, migrate_gdunit4_source,
35};
36pub use gut_migrate::{
37 GutMigrationAction, GutMigrationIssue, GutMigrationOutput, GutMigrationStatus,
38 is_gut_test_file, is_gut_test_source, migrate_gut_file, migrate_gut_source,
39};
40pub use rewrite::{RewriteOutput, rewrite_file, rewrite_source};
41
42use std::path::{Path, PathBuf};
43
44use anyhow::{Context, Result, anyhow};
45use gdsyntax::ast::{AstNode, Member};
46use walkdir::WalkDir;
47
48pub fn resolve(
55 inputs: &[String],
56 project_path: &Path,
57 patterns: &[String],
58 exclude_patterns: &[String],
59) -> Result<Vec<String>> {
60 let project_abs = project_path
61 .canonicalize()
62 .with_context(|| format!("project path does not exist: {}", project_path.display()))?;
63 let mut out: Vec<String> = Vec::new();
64 for input in inputs {
65 if let Some(res) = input.strip_prefix("res://") {
66 out.push(format!("res://{res}"));
67 continue;
68 }
69 let path = PathBuf::from(input);
70 let abs = if path.is_absolute() {
71 path.clone()
72 } else {
73 project_abs.join(&path)
74 };
75 let abs = abs.canonicalize().unwrap_or(abs);
77 if abs.is_file() {
78 if abs.extension().and_then(|e| e.to_str()) != Some("gd") {
79 return Err(anyhow!("not a .gd file: {}", abs.display()));
80 }
81 out.push(to_res_path(&abs, &project_abs)?);
82 } else if abs.is_dir() {
83 let walker = WalkDir::new(&abs)
84 .follow_links(false)
85 .into_iter()
86 .filter_entry(|entry| entry.depth() == 0 || !is_skipped_dir(entry));
87 for entry in walker {
88 let entry = entry.with_context(|| format!("walking {}", abs.display()))?;
89 if !entry.file_type().is_file() {
90 continue;
91 }
92 if entry.path().extension().and_then(|e| e.to_str()) != Some("gd") {
93 continue;
94 }
95 let ep = entry
96 .path()
97 .canonicalize()
98 .unwrap_or_else(|_| entry.path().to_path_buf());
99 let rel = ep.strip_prefix(&project_abs).unwrap_or(&ep);
101 let rel_str = rel.to_string_lossy().replace('\\', "/");
102 if !patterns.iter().any(|p| glob_match(p, &rel_str)) {
103 continue;
104 }
105 if exclude_patterns.iter().any(|p| glob_match(p, &rel_str)) {
106 continue;
107 }
108 out.push(to_res_path(&ep, &project_abs)?);
109 }
110 } else {
111 return Err(anyhow!("path not found: {}", abs.display()));
112 }
113 }
114 out.sort();
115 out.dedup();
116 Ok(out)
117}
118
119fn is_skipped_dir(entry: &walkdir::DirEntry) -> bool {
122 if !entry.file_type().is_dir() {
123 return false;
124 }
125 let name = entry.file_name().to_string_lossy();
126 name.starts_with('.')
127 || name == "addons"
128 || entry.path().join(".gdignore").exists()
129 || entry.path().join("project.godot").exists()
130}
131
132pub fn glob_match(pattern: &str, path: &str) -> bool {
135 let pat: Vec<&str> = pattern.split('/').collect();
136 let seg: Vec<&str> = path.split('/').collect();
137 glob_seg(&pat, &seg)
138}
139
140fn glob_seg(pat: &[&str], seg: &[&str]) -> bool {
141 if pat.is_empty() {
142 return seg.is_empty();
143 }
144 if pat[0] == "**" {
145 for i in 0..=seg.len() {
146 if glob_seg(&pat[1..], &seg[i..]) {
147 return true;
148 }
149 }
150 return false;
151 }
152 if seg.is_empty() {
153 return false;
154 }
155 if seg_matches(pat[0], seg[0]) {
156 return glob_seg(&pat[1..], &seg[1..]);
157 }
158 false
159}
160
161fn seg_matches(pattern: &str, s: &str) -> bool {
163 if !pattern.contains('*') {
164 return pattern == s;
165 }
166 let parts: Vec<&str> = pattern.split('*').collect();
167 let mut pos = 0usize;
168 for (i, part) in parts.iter().enumerate() {
169 if i == 0 {
170 if !s.starts_with(part) {
171 return false;
172 }
173 pos += part.len();
174 } else if i == parts.len() - 1 {
175 return s[pos..].ends_with(part);
176 } else {
177 match s[pos..].find(part) {
178 None => return false,
179 Some(idx) => pos += idx + part.len(),
180 }
181 }
182 }
183 true
184}
185
186pub fn extract_describe_dir(path: &Path) -> Option<String> {
189 path.components()
190 .filter_map(|c| match c {
191 std::path::Component::Normal(s) => s.to_str(),
192 _ => None,
193 })
194 .rfind(|s| s.starts_with("describe_"))
195 .map(|s| s.to_string())
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum TestKind {
201 Normal,
203 Bench,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum IsolationDirective {
210 File,
211 Test,
212}
213
214#[derive(Debug, Clone)]
216pub struct TestEntry {
217 pub name: String,
218 pub kind: TestKind,
219 pub tags: Vec<String>,
221 pub isolation: Option<IsolationDirective>,
223}
224
225pub fn list_test_entries_in(script_path: &Path) -> Result<Vec<TestEntry>> {
231 let source =
232 std::fs::read(script_path).with_context(|| format!("reading {}", script_path.display()))?;
233 let mut entries = list_test_entries_in_source(&source)?;
234 if let Some(prefix) = extract_describe_dir(script_path) {
235 for entry in &mut entries {
236 entry.name = format!("{prefix}/{}", entry.name);
237 }
238 }
239 Ok(entries)
240}
241
242pub fn list_test_entries_in_source(source: &[u8]) -> Result<Vec<TestEntry>> {
243 let tree = gdsyntax::parse_bytes(source);
246 let file_isolation = extract_file_isolation(source);
247 let mut out = Vec::new();
248 collect_test_entries(
249 &tree,
250 tree.file().members(),
251 source,
252 None,
253 file_isolation,
254 &mut out,
255 );
256 Ok(out)
257}
258
259fn parse_tag_comment(text: &str) -> Option<String> {
261 let body = text.trim().strip_prefix("##")?.trim_start();
262 let name = body.strip_prefix("@tag")?.trim();
263 if name.is_empty() || name.contains(char::is_whitespace) {
264 return None;
265 }
266 Some(name.to_string())
267}
268
269fn parse_isolation_comment(text: &str) -> Option<IsolationDirective> {
270 let trimmed = text.trim();
271 let doc_body = trimmed.strip_prefix("##").map(str::trim_start);
272 let plain_body = trimmed.strip_prefix('#').map(str::trim_start);
273
274 if let Some(body) = doc_body
275 && let Some(value) = body.strip_prefix("@isolation").map(str::trim)
276 {
277 return match value {
278 "file" => Some(IsolationDirective::File),
279 "test" => Some(IsolationDirective::Test),
280 _ => None,
281 };
282 }
283
284 if let Some(body) = plain_body
285 && let Some(value) = body
286 .strip_prefix("gdtest:")
287 .map(str::trim_start)
288 .and_then(|rest| rest.strip_prefix("isolation="))
289 .map(str::trim)
290 {
291 return match value {
292 "file" => Some(IsolationDirective::File),
293 "test" => Some(IsolationDirective::Test),
294 _ => None,
295 };
296 }
297
298 None
299}
300
301fn extract_tags_preceding(source: &[u8], func_row: usize) -> Vec<String> {
304 let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
305 let mut tags = Vec::new();
306 let mut row = func_row;
307 while row > 0 {
308 row -= 1;
309 let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
310 if line.is_empty() {
311 break;
312 }
313 if let Some(tag) = parse_tag_comment(line) {
314 tags.push(tag);
315 } else if line.starts_with('#') {
316 } else {
318 break;
319 }
320 }
321 tags.reverse();
322 tags
323}
324
325fn extract_isolation_preceding(source: &[u8], func_row: usize) -> Option<IsolationDirective> {
326 let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
327 let mut row = func_row;
328 while row > 0 {
329 row -= 1;
330 let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
331 if line.is_empty() {
332 break;
333 }
334 if let Some(isolation) = parse_isolation_comment(line) {
335 return Some(isolation);
336 }
337 if line.starts_with('#') {
338 continue;
339 }
340 break;
341 }
342 None
343}
344
345fn extract_file_isolation(source: &[u8]) -> Option<IsolationDirective> {
346 for raw_line in source.split(|&b| b == b'\n') {
347 let line = std::str::from_utf8(raw_line).unwrap_or("").trim();
348 if line.is_empty() {
349 continue;
350 }
351 if let Some(isolation) = parse_isolation_comment(line) {
352 return Some(isolation);
353 }
354 if line.starts_with('#') {
355 continue;
356 }
357 break;
358 }
359 None
360}
361
362fn classify_func(name: &str) -> Option<TestKind> {
364 if name.starts_with("bench_") {
365 Some(TestKind::Bench)
366 } else if name.starts_with("test_") {
367 Some(TestKind::Normal)
368 } else {
369 None
370 }
371}
372
373fn collect_test_entries(
376 tree: &gdsyntax::SyntaxTree,
377 members: impl Iterator<Item = Member>,
378 source: &[u8],
379 describe: Option<&str>,
380 file_isolation: Option<IsolationDirective>,
381 out: &mut Vec<TestEntry>,
382) {
383 for member in members {
384 match member {
385 Member::Func(func) => {
386 let Some(name_token) = func.name() else {
387 continue;
388 };
389 let fname = name_token.text();
390 let Some(kind) = classify_func(fname) else {
391 continue;
392 };
393 let name = match describe {
394 Some(cls) => format!("{cls}/{fname}"),
395 None => fname.to_string(),
396 };
397 let row = tree
400 .position(func.syntax().text_range().start().into())
401 .line
402 - 1;
403 let tags = extract_tags_preceding(source, row);
404 let isolation = extract_isolation_preceding(source, row).or(file_isolation);
405 out.push(TestEntry {
406 name,
407 kind,
408 tags,
409 isolation,
410 });
411 }
412 Member::Class(class) if describe.is_none() => {
415 if let Some(name_token) = class.name()
416 && name_token.text().starts_with("describe_")
417 {
418 let members: Vec<Member> = class
419 .body()
420 .map(|body| body.members().collect())
421 .unwrap_or_default();
422 collect_test_entries(
423 tree,
424 members.into_iter(),
425 source,
426 Some(name_token.text()),
427 file_isolation,
428 out,
429 );
430 }
431 }
432 _ => {}
433 }
434 }
435}
436
437pub fn list_tests_in(script_path: &Path) -> Result<Vec<String>> {
440 let entries = list_test_entries_in(script_path)?;
441 Ok(entries
442 .into_iter()
443 .filter(|e| e.kind == TestKind::Normal)
444 .map(|e| e.name)
445 .collect())
446}
447
448pub fn to_res_path(file: &Path, project_abs: &Path) -> Result<String> {
451 let rel = file.strip_prefix(project_abs).with_context(|| {
452 format!(
453 "{} is outside project {}",
454 file.display(),
455 project_abs.display()
456 )
457 })?;
458 let s = rel.to_string_lossy().replace('\\', "/");
459 Ok(format!("res://{s}"))
460}
461
462pub fn res_to_abs(res_path: &str, project_abs: &Path) -> Result<PathBuf> {
464 let rel = res_path
465 .strip_prefix("res://")
466 .ok_or_else(|| anyhow!("expected res:// prefix: {res_path}"))?;
467 Ok(project_abs.join(rel))
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use std::fs;
474 use std::path::PathBuf;
475 use std::time::{SystemTime, UNIX_EPOCH};
476
477 fn make_temp_project(prefix: &str) -> PathBuf {
478 let nonce = SystemTime::now()
479 .duration_since(UNIX_EPOCH)
480 .unwrap()
481 .as_nanos();
482 let dir = std::env::temp_dir().join(format!("gdtest-{prefix}-{nonce}"));
483 fs::create_dir_all(&dir).unwrap();
484 dir
485 }
486
487 #[test]
488 fn extracts_test_functions() {
489 let src = br#"
490extends RefCounted
491
492func _helper() -> void:
493 pass
494
495func test_alpha() -> void:
496 pass
497
498func test_beta(arg: int) -> void:
499 # func test_inside_comment() should NOT be picked up
500 pass
501
502func not_a_test() -> void:
503 pass
504
505# Async test
506func test_gamma() -> void:
507 await physics_frame
508"#;
509 let entries = list_test_entries_in_source(src).unwrap();
510 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
511 assert_eq!(names, vec!["test_alpha", "test_beta", "test_gamma"]);
512 assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
513 }
514
515 #[test]
516 fn unknown_prefixes_are_not_tests() {
517 let src = br#"
518func test_normal() -> void:
519 pass
520func xtest_skipped() -> void:
521 pass
522func ftest_focused() -> void:
523 pass
524func helper() -> void:
525 pass
526"#;
527 let entries = list_test_entries_in_source(src).unwrap();
528 assert_eq!(entries.len(), 1);
530 assert_eq!(entries[0].name, "test_normal");
531 assert_eq!(entries[0].kind, TestKind::Normal);
532 }
533
534 #[test]
535 fn parses_tag_comments() {
536 let src = br#"
537extends GdTest
538
539## @tag slow
540## @tag integration
541func test_heavy() -> void:
542 pass
543
544## regular comment, not a tag
545func test_untagged() -> void:
546 pass
547
548func test_also_untagged() -> void:
549 pass
550"#;
551 let entries = list_test_entries_in_source(src).unwrap();
552 assert_eq!(entries.len(), 3);
553 assert_eq!(entries[0].name, "test_heavy");
554 assert_eq!(entries[0].tags, vec!["slow", "integration"]);
555 assert!(entries[1].tags.is_empty());
556 assert!(entries[2].tags.is_empty());
557 }
558
559 #[test]
560 fn parses_file_header_isolation_directive() {
561 let src = br#"
562# gdtest: isolation=test
563extends GdTest
564
565func test_one() -> void:
566 pass
567
568func test_two() -> void:
569 pass
570"#;
571 let entries = list_test_entries_in_source(src).unwrap();
572 assert_eq!(entries.len(), 2);
573 assert_eq!(entries[0].isolation, Some(IsolationDirective::Test));
574 assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
575 }
576
577 #[test]
578 fn function_isolation_overrides_file_header() {
579 let src = br#"
580## @isolation test
581extends GdTest
582
583## @isolation file
584func test_one() -> void:
585 pass
586
587func test_two() -> void:
588 pass
589"#;
590 let entries = list_test_entries_in_source(src).unwrap();
591 assert_eq!(entries.len(), 2);
592 assert_eq!(entries[0].isolation, Some(IsolationDirective::File));
593 assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
594 }
595
596 #[test]
597 fn classifies_bench_funcs() {
598 let src = br#"
599func test_normal() -> void:
600 pass
601func bench_pathfinding() -> void:
602 pass
603"#;
604 let entries = list_test_entries_in_source(src).unwrap();
605 assert_eq!(entries.len(), 2);
606 assert_eq!(entries[0].kind, TestKind::Normal);
607 assert_eq!(entries[1].kind, TestKind::Bench);
608 let runnable: Vec<String> = entries
610 .into_iter()
611 .filter(|e| e.kind == TestKind::Normal)
612 .map(|e| e.name)
613 .collect();
614 assert_eq!(runnable, vec!["test_normal"]);
615 }
616
617 #[test]
618 fn empty_on_parse_error_tolerates_partial() {
619 let src = br#"func test_recoverable(): pass
620!@#$%garbage that breaks parsing"#;
621 let _entries = list_test_entries_in_source(src).unwrap();
622 }
623
624 #[test]
625 fn discovers_describe_class_tests() {
626 let src = br#"
627extends GdTest
628
629func test_top_level() -> void:
630 pass
631
632class describe_Movement:
633 func test_moves_right() -> void:
634 pass
635 func test_moves_left() -> void:
636 pass
637 func helper_not_a_test() -> void:
638 pass
639
640class IgnoredClass:
641 func test_inside_ignored() -> void:
642 pass
643"#;
644 let entries = list_test_entries_in_source(src).unwrap();
645 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
646 assert_eq!(
647 names,
648 vec![
649 "test_top_level",
650 "describe_Movement/test_moves_right",
651 "describe_Movement/test_moves_left",
652 ]
653 );
654 assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
655 }
656
657 #[test]
658 fn discovers_non_ascii_test_names() {
659 let src = "func test_日本語() -> void:\n\tpass\n".as_bytes();
660 let entries = list_test_entries_in_source(src).unwrap();
661 assert_eq!(entries[0].name, "test_日本語");
662 }
663
664 #[test]
665 fn reads_tags_above_annotations() {
666 let src = br#"
667## @tag slow
668@warning_ignore("unused_variable")
669func test_annotated() -> void:
670 var x := 1
671"#;
672 let entries = list_test_entries_in_source(src).unwrap();
673 assert_eq!(entries[0].tags, vec!["slow"]);
674 }
675
676 #[test]
677 fn finds_tests_after_a_syntax_error() {
678 let src = b"func broken(:\n\tpass\n\nfunc test_after() -> void:\n\tpass\n";
679 let entries = list_test_entries_in_source(src).unwrap();
680 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
681 assert_eq!(names, vec!["test_after"]);
682 }
683
684 #[test]
685 fn skips_classes_nested_in_describe_classes() {
686 let src = br#"
687class describe_Outer:
688 func test_outer() -> void:
689 pass
690 class describe_Inner:
691 func test_inner() -> void:
692 pass
693"#;
694 let entries = list_test_entries_in_source(src).unwrap();
695 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
696 assert_eq!(names, vec!["describe_Outer/test_outer"]);
697 }
698
699 #[test]
702 fn glob_match_test_dir_pattern() {
703 assert!(glob_match("test/**/*.gd", "test/battle.gd"));
704 assert!(glob_match("test/**/*.gd", "test/docs/test_foo.gd"));
705 assert!(glob_match("test/**/*.gd", "test/docs/dungeon/battle.gd"));
706 assert!(!glob_match("test/**/*.gd", "src/battle.gd"));
707 assert!(!glob_match("test/**/*.gd", "test/battle.tscn"));
708 }
709
710 #[test]
711 fn glob_match_colocated_pattern() {
712 assert!(glob_match("**/*.test.gd", "battle.test.gd"));
713 assert!(glob_match("**/*.test.gd", "src/battle/battle.test.gd"));
714 assert!(glob_match("**/*.test.gd", "deep/nested/dir/foo.test.gd"));
715 assert!(!glob_match("**/*.test.gd", "test_battle.gd"));
716 assert!(!glob_match("**/*.test.gd", "battle.gd"));
717 }
718
719 #[test]
720 fn glob_match_star_prefix_legacy() {
721 assert!(glob_match("test/test_*.gd", "test/test_battle.gd"));
723 assert!(!glob_match("test/test_*.gd", "test/battle.gd"));
724 }
725
726 #[test]
729 fn extract_describe_dir_finds_prefix() {
730 use std::path::PathBuf;
731 let p = PathBuf::from("test/describe_Battle/basic_hit.gd");
732 assert_eq!(
733 extract_describe_dir(&p),
734 Some("describe_Battle".to_string())
735 );
736 }
737
738 #[test]
739 fn extract_describe_dir_none_for_normal() {
740 use std::path::PathBuf;
741 let p = PathBuf::from("test/docs/test_foo.gd");
742 assert_eq!(extract_describe_dir(&p), None);
743 }
744
745 #[test]
746 fn extract_describe_dir_returns_last() {
747 use std::path::PathBuf;
748 let p = PathBuf::from("test/describe_Outer/describe_Inner/foo.gd");
750 assert_eq!(extract_describe_dir(&p), Some("describe_Inner".to_string()));
751 }
752
753 #[test]
754 fn resolve_excludes_discovery_globs_for_directory_walks() {
755 let project = make_temp_project("resolve-exclude-dir");
756 fs::create_dir_all(project.join("test/helpers")).unwrap();
757 fs::create_dir_all(project.join("test/cases")).unwrap();
758 fs::write(
759 project.join("test/helpers/helper.gd"),
760 "extends RefCounted\n",
761 )
762 .unwrap();
763 fs::write(
764 project.join("test/cases/test_ok.gd"),
765 "extends RefCounted\n",
766 )
767 .unwrap();
768
769 let inputs = vec!["test".to_string()];
770 let patterns = vec!["test/**/*.gd".to_string()];
771 let excludes = vec!["test/helpers/**".to_string()];
772 let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
773
774 assert_eq!(resolved, vec!["res://test/cases/test_ok.gd"]);
775
776 fs::remove_dir_all(project).ok();
777 }
778
779 #[test]
780 fn resolve_keeps_explicit_file_targets_even_if_excluded() {
781 let project = make_temp_project("resolve-explicit-file");
782 fs::create_dir_all(project.join("test/helpers")).unwrap();
783 let helper = project.join("test/helpers/helper.gd");
784 fs::write(&helper, "extends RefCounted\n").unwrap();
785
786 let inputs = vec![helper.to_string_lossy().into_owned()];
787 let patterns = vec!["test/**/*.gd".to_string()];
788 let excludes = vec!["test/helpers/**".to_string()];
789 let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
790
791 assert_eq!(resolved, vec!["res://test/helpers/helper.gd"]);
792
793 fs::remove_dir_all(project).ok();
794 }
795
796 #[test]
797 fn walking_skips_addons_hidden_ignored_and_nested_projects() {
798 let project = make_temp_project("resolve-skips");
799 for (path, content) in [
800 ("test/test_ok.gd", ""),
801 ("feature/thing.test.gd", ""),
802 ("addons/plugin/plugin.test.gd", ""),
803 ("addons/plugin/test/test_plugin.gd", ""),
804 (".godot/cache/old.test.gd", ""),
805 ("generated/.gdignore", ""),
806 ("generated/gen.test.gd", ""),
807 ("server/project.godot", ""),
808 ("server/net.test.gd", ""),
809 ] {
810 let path = project.join(path);
811 fs::create_dir_all(path.parent().unwrap()).unwrap();
812 fs::write(path, content).unwrap();
813 }
814 let patterns: Vec<String> = DEFAULT_PATTERNS.iter().map(|p| p.to_string()).collect();
815
816 let resolved = resolve(&[".".to_string()], &project, &patterns, &[]).unwrap();
817 assert_eq!(
818 resolved,
819 vec!["res://feature/thing.test.gd", "res://test/test_ok.gd"]
820 );
821
822 let resolved = resolve(&["addons/plugin".to_string()], &project, &patterns, &[]).unwrap();
824 assert_eq!(resolved, vec!["res://addons/plugin/plugin.test.gd"]);
825
826 fs::remove_dir_all(project).ok();
827 }
828}