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 tree_sitter::Parser;
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 mut parser = Parser::new();
222 parser
223 .set_language(&tree_sitter_gdscript::LANGUAGE.into())
224 .context("failed to set GDScript language")?;
225 let Some(tree) = parser.parse(source, None) else {
226 return Ok(Vec::new());
227 };
228 let file_isolation = extract_file_isolation(source);
229 Ok(collect_test_entries(
230 tree.root_node(),
231 source,
232 None,
233 file_isolation,
234 ))
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<'a>(
354 node: tree_sitter::Node<'a>,
355 source: &[u8],
356 describe: Option<&str>,
357 file_isolation: Option<IsolationDirective>,
358) -> Vec<TestEntry> {
359 let mut out = Vec::new();
360 for i in 0..node.child_count() {
361 let Some(child) = node.child(i as u32) else {
362 continue;
363 };
364 match child.kind() {
365 "function_definition" => {
366 if let Some(name_node) = child.child_by_field_name("name")
367 && let Ok(fname) = name_node.utf8_text(source)
368 && let Some(kind) = classify_func(fname)
369 {
370 let name = match describe {
371 Some(cls) => format!("{cls}/{fname}"),
372 None => fname.to_string(),
373 };
374 let tags = extract_tags_preceding(source, child.start_position().row);
375 let isolation = extract_isolation_preceding(source, child.start_position().row)
376 .or(file_isolation);
377 out.push(TestEntry {
378 name,
379 kind,
380 tags,
381 isolation,
382 });
383 }
384 }
386 "class_definition" if describe.is_none() => {
387 if let Some(name_node) = child.child_by_field_name("name")
389 && let Ok(cname) = name_node.utf8_text(source)
390 && cname.starts_with("describe_")
391 {
392 out.extend(collect_test_entries(
393 child,
394 source,
395 Some(cname),
396 file_isolation,
397 ));
398 }
399 }
401 _ => {
402 out.extend(collect_test_entries(
403 child,
404 source,
405 describe,
406 file_isolation,
407 ));
408 }
409 }
410 }
411 out
412}
413
414pub fn list_tests_in(script_path: &Path) -> Result<Vec<String>> {
417 let entries = list_test_entries_in(script_path)?;
418 Ok(entries
419 .into_iter()
420 .filter(|e| e.kind == TestKind::Normal)
421 .map(|e| e.name)
422 .collect())
423}
424
425pub fn to_res_path(file: &Path, project_abs: &Path) -> Result<String> {
428 let rel = file.strip_prefix(project_abs).with_context(|| {
429 format!(
430 "{} is outside project {}",
431 file.display(),
432 project_abs.display()
433 )
434 })?;
435 let s = rel.to_string_lossy().replace('\\', "/");
436 Ok(format!("res://{s}"))
437}
438
439pub fn res_to_abs(res_path: &str, project_abs: &Path) -> Result<PathBuf> {
441 let rel = res_path
442 .strip_prefix("res://")
443 .ok_or_else(|| anyhow!("expected res:// prefix: {res_path}"))?;
444 Ok(project_abs.join(rel))
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use std::fs;
451 use std::path::PathBuf;
452 use std::time::{SystemTime, UNIX_EPOCH};
453
454 fn make_temp_project(prefix: &str) -> PathBuf {
455 let nonce = SystemTime::now()
456 .duration_since(UNIX_EPOCH)
457 .unwrap()
458 .as_nanos();
459 let dir = std::env::temp_dir().join(format!("gdtest-{prefix}-{nonce}"));
460 fs::create_dir_all(&dir).unwrap();
461 dir
462 }
463
464 #[test]
465 fn extracts_test_functions() {
466 let src = br#"
467extends RefCounted
468
469func _helper() -> void:
470 pass
471
472func test_alpha() -> void:
473 pass
474
475func test_beta(arg: int) -> void:
476 # func test_inside_comment() should NOT be picked up
477 pass
478
479func not_a_test() -> void:
480 pass
481
482# Async test
483func test_gamma() -> void:
484 await physics_frame
485"#;
486 let entries = list_test_entries_in_source(src).unwrap();
487 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
488 assert_eq!(names, vec!["test_alpha", "test_beta", "test_gamma"]);
489 assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
490 }
491
492 #[test]
493 fn unknown_prefixes_are_not_tests() {
494 let src = br#"
495func test_normal() -> void:
496 pass
497func xtest_skipped() -> void:
498 pass
499func ftest_focused() -> void:
500 pass
501func helper() -> void:
502 pass
503"#;
504 let entries = list_test_entries_in_source(src).unwrap();
505 assert_eq!(entries.len(), 1);
507 assert_eq!(entries[0].name, "test_normal");
508 assert_eq!(entries[0].kind, TestKind::Normal);
509 }
510
511 #[test]
512 fn parses_tag_comments() {
513 let src = br#"
514extends GdTest
515
516## @tag slow
517## @tag integration
518func test_heavy() -> void:
519 pass
520
521## regular comment, not a tag
522func test_untagged() -> void:
523 pass
524
525func test_also_untagged() -> void:
526 pass
527"#;
528 let entries = list_test_entries_in_source(src).unwrap();
529 assert_eq!(entries.len(), 3);
530 assert_eq!(entries[0].name, "test_heavy");
531 assert_eq!(entries[0].tags, vec!["slow", "integration"]);
532 assert!(entries[1].tags.is_empty());
533 assert!(entries[2].tags.is_empty());
534 }
535
536 #[test]
537 fn parses_file_header_isolation_directive() {
538 let src = br#"
539# gdtest: isolation=test
540extends GdTest
541
542func test_one() -> void:
543 pass
544
545func test_two() -> void:
546 pass
547"#;
548 let entries = list_test_entries_in_source(src).unwrap();
549 assert_eq!(entries.len(), 2);
550 assert_eq!(entries[0].isolation, Some(IsolationDirective::Test));
551 assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
552 }
553
554 #[test]
555 fn function_isolation_overrides_file_header() {
556 let src = br#"
557## @isolation test
558extends GdTest
559
560## @isolation file
561func test_one() -> void:
562 pass
563
564func test_two() -> void:
565 pass
566"#;
567 let entries = list_test_entries_in_source(src).unwrap();
568 assert_eq!(entries.len(), 2);
569 assert_eq!(entries[0].isolation, Some(IsolationDirective::File));
570 assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
571 }
572
573 #[test]
574 fn classifies_bench_funcs() {
575 let src = br#"
576func test_normal() -> void:
577 pass
578func bench_pathfinding() -> void:
579 pass
580"#;
581 let entries = list_test_entries_in_source(src).unwrap();
582 assert_eq!(entries.len(), 2);
583 assert_eq!(entries[0].kind, TestKind::Normal);
584 assert_eq!(entries[1].kind, TestKind::Bench);
585 let runnable: Vec<String> = entries
587 .into_iter()
588 .filter(|e| e.kind == TestKind::Normal)
589 .map(|e| e.name)
590 .collect();
591 assert_eq!(runnable, vec!["test_normal"]);
592 }
593
594 #[test]
595 fn empty_on_parse_error_tolerates_partial() {
596 let src = br#"func test_recoverable(): pass
597!@#$%garbage that breaks parsing"#;
598 let _entries = list_test_entries_in_source(src).unwrap();
599 }
600
601 #[test]
602 fn discovers_describe_class_tests() {
603 let src = br#"
604extends GdTest
605
606func test_top_level() -> void:
607 pass
608
609class describe_Movement:
610 func test_moves_right() -> void:
611 pass
612 func test_moves_left() -> void:
613 pass
614 func helper_not_a_test() -> void:
615 pass
616
617class IgnoredClass:
618 func test_inside_ignored() -> void:
619 pass
620"#;
621 let entries = list_test_entries_in_source(src).unwrap();
622 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
623 assert_eq!(
624 names,
625 vec![
626 "test_top_level",
627 "describe_Movement/test_moves_right",
628 "describe_Movement/test_moves_left",
629 ]
630 );
631 assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
632 }
633
634 #[test]
637 fn glob_match_test_dir_pattern() {
638 assert!(glob_match("test/**/*.gd", "test/battle.gd"));
639 assert!(glob_match("test/**/*.gd", "test/docs/test_foo.gd"));
640 assert!(glob_match("test/**/*.gd", "test/docs/dungeon/battle.gd"));
641 assert!(!glob_match("test/**/*.gd", "src/battle.gd"));
642 assert!(!glob_match("test/**/*.gd", "test/battle.tscn"));
643 }
644
645 #[test]
646 fn glob_match_colocated_pattern() {
647 assert!(glob_match("**/*.test.gd", "battle.test.gd"));
648 assert!(glob_match("**/*.test.gd", "src/battle/battle.test.gd"));
649 assert!(glob_match("**/*.test.gd", "deep/nested/dir/foo.test.gd"));
650 assert!(!glob_match("**/*.test.gd", "test_battle.gd"));
651 assert!(!glob_match("**/*.test.gd", "battle.gd"));
652 }
653
654 #[test]
655 fn glob_match_star_prefix_legacy() {
656 assert!(glob_match("test/test_*.gd", "test/test_battle.gd"));
658 assert!(!glob_match("test/test_*.gd", "test/battle.gd"));
659 }
660
661 #[test]
664 fn extract_describe_dir_finds_prefix() {
665 use std::path::PathBuf;
666 let p = PathBuf::from("test/describe_Battle/basic_hit.gd");
667 assert_eq!(
668 extract_describe_dir(&p),
669 Some("describe_Battle".to_string())
670 );
671 }
672
673 #[test]
674 fn extract_describe_dir_none_for_normal() {
675 use std::path::PathBuf;
676 let p = PathBuf::from("test/docs/test_foo.gd");
677 assert_eq!(extract_describe_dir(&p), None);
678 }
679
680 #[test]
681 fn extract_describe_dir_returns_last() {
682 use std::path::PathBuf;
683 let p = PathBuf::from("test/describe_Outer/describe_Inner/foo.gd");
685 assert_eq!(extract_describe_dir(&p), Some("describe_Inner".to_string()));
686 }
687
688 #[test]
689 fn resolve_excludes_discovery_globs_for_directory_walks() {
690 let project = make_temp_project("resolve-exclude-dir");
691 fs::create_dir_all(project.join("test/helpers")).unwrap();
692 fs::create_dir_all(project.join("test/cases")).unwrap();
693 fs::write(
694 project.join("test/helpers/helper.gd"),
695 "extends RefCounted\n",
696 )
697 .unwrap();
698 fs::write(
699 project.join("test/cases/test_ok.gd"),
700 "extends RefCounted\n",
701 )
702 .unwrap();
703
704 let inputs = vec!["test".to_string()];
705 let patterns = vec!["test/**/*.gd".to_string()];
706 let excludes = vec!["test/helpers/**".to_string()];
707 let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
708
709 assert_eq!(resolved, vec!["res://test/cases/test_ok.gd"]);
710
711 fs::remove_dir_all(project).ok();
712 }
713
714 #[test]
715 fn resolve_keeps_explicit_file_targets_even_if_excluded() {
716 let project = make_temp_project("resolve-explicit-file");
717 fs::create_dir_all(project.join("test/helpers")).unwrap();
718 let helper = project.join("test/helpers/helper.gd");
719 fs::write(&helper, "extends RefCounted\n").unwrap();
720
721 let inputs = vec![helper.to_string_lossy().into_owned()];
722 let patterns = vec!["test/**/*.gd".to_string()];
723 let excludes = vec!["test/helpers/**".to_string()];
724 let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
725
726 assert_eq!(resolved, vec!["res://test/helpers/helper.gd"]);
727
728 fs::remove_dir_all(project).ok();
729 }
730}