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