pub const DEFAULT_PATTERNS: &[&str] = &["test/**/*.gd", "**/*.test.gd"];
pub mod coverage;
pub mod deps;
pub mod gdunit4_migrate;
pub mod gut_migrate;
pub mod mutate;
pub mod rewrite;
pub use gdunit4_migrate::{
GdUnit4MigrationAction, GdUnit4MigrationIssue, GdUnit4MigrationOutput, GdUnit4MigrationStatus,
is_gdunit4_test_file, is_gdunit4_test_source, migrate_gdunit4_file, migrate_gdunit4_source,
};
pub use gut_migrate::{
GutMigrationAction, GutMigrationIssue, GutMigrationOutput, GutMigrationStatus,
is_gut_test_file, is_gut_test_source, migrate_gut_file, migrate_gut_source,
};
pub use rewrite::{RewriteOutput, rewrite_file, rewrite_source};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use gdsyntax::ast::{AstNode, Member};
use walkdir::WalkDir;
pub fn resolve(
inputs: &[String],
project_path: &Path,
patterns: &[String],
exclude_patterns: &[String],
) -> Result<Vec<String>> {
let project_abs = project_path
.canonicalize()
.with_context(|| format!("project path does not exist: {}", project_path.display()))?;
let mut out: Vec<String> = Vec::new();
for input in inputs {
if let Some(res) = input.strip_prefix("res://") {
out.push(format!("res://{res}"));
continue;
}
let path = PathBuf::from(input);
let abs = if path.is_absolute() {
path.clone()
} else {
project_abs.join(&path)
};
let abs = abs.canonicalize().unwrap_or(abs);
if abs.is_file() {
if abs.extension().and_then(|e| e.to_str()) != Some("gd") {
return Err(anyhow!("not a .gd file: {}", abs.display()));
}
out.push(to_res_path(&abs, &project_abs)?);
} else if abs.is_dir() {
for entry in WalkDir::new(&abs).follow_links(false) {
let entry = entry.with_context(|| format!("walking {}", abs.display()))?;
if !entry.file_type().is_file() {
continue;
}
if entry.path().extension().and_then(|e| e.to_str()) != Some("gd") {
continue;
}
let ep = entry
.path()
.canonicalize()
.unwrap_or_else(|_| entry.path().to_path_buf());
let rel = ep.strip_prefix(&project_abs).unwrap_or(&ep);
let rel_str = rel.to_string_lossy().replace('\\', "/");
if !patterns.iter().any(|p| glob_match(p, &rel_str)) {
continue;
}
if exclude_patterns.iter().any(|p| glob_match(p, &rel_str)) {
continue;
}
out.push(to_res_path(&ep, &project_abs)?);
}
} else {
return Err(anyhow!("path not found: {}", abs.display()));
}
}
out.sort();
out.dedup();
Ok(out)
}
pub fn glob_match(pattern: &str, path: &str) -> bool {
let pat: Vec<&str> = pattern.split('/').collect();
let seg: Vec<&str> = path.split('/').collect();
glob_seg(&pat, &seg)
}
fn glob_seg(pat: &[&str], seg: &[&str]) -> bool {
if pat.is_empty() {
return seg.is_empty();
}
if pat[0] == "**" {
for i in 0..=seg.len() {
if glob_seg(&pat[1..], &seg[i..]) {
return true;
}
}
return false;
}
if seg.is_empty() {
return false;
}
if seg_matches(pat[0], seg[0]) {
return glob_seg(&pat[1..], &seg[1..]);
}
false
}
fn seg_matches(pattern: &str, s: &str) -> bool {
if !pattern.contains('*') {
return pattern == s;
}
let parts: Vec<&str> = pattern.split('*').collect();
let mut pos = 0usize;
for (i, part) in parts.iter().enumerate() {
if i == 0 {
if !s.starts_with(part) {
return false;
}
pos += part.len();
} else if i == parts.len() - 1 {
return s[pos..].ends_with(part);
} else {
match s[pos..].find(part) {
None => return false,
Some(idx) => pos += idx + part.len(),
}
}
}
true
}
pub fn extract_describe_dir(path: &Path) -> Option<String> {
path.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => s.to_str(),
_ => None,
})
.rfind(|s| s.starts_with("describe_"))
.map(|s| s.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestKind {
Normal,
Bench,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IsolationDirective {
File,
Test,
}
#[derive(Debug, Clone)]
pub struct TestEntry {
pub name: String,
pub kind: TestKind,
pub tags: Vec<String>,
pub isolation: Option<IsolationDirective>,
}
pub fn list_test_entries_in(script_path: &Path) -> Result<Vec<TestEntry>> {
let source =
std::fs::read(script_path).with_context(|| format!("reading {}", script_path.display()))?;
let mut entries = list_test_entries_in_source(&source)?;
if let Some(prefix) = extract_describe_dir(script_path) {
for entry in &mut entries {
entry.name = format!("{prefix}/{}", entry.name);
}
}
Ok(entries)
}
pub fn list_test_entries_in_source(source: &[u8]) -> Result<Vec<TestEntry>> {
let tree = gdsyntax::parse_bytes(source);
let file_isolation = extract_file_isolation(source);
let mut out = Vec::new();
collect_test_entries(
&tree,
tree.file().members(),
source,
None,
file_isolation,
&mut out,
);
Ok(out)
}
fn parse_tag_comment(text: &str) -> Option<String> {
let body = text.trim().strip_prefix("##")?.trim_start();
let name = body.strip_prefix("@tag")?.trim();
if name.is_empty() || name.contains(char::is_whitespace) {
return None;
}
Some(name.to_string())
}
fn parse_isolation_comment(text: &str) -> Option<IsolationDirective> {
let trimmed = text.trim();
let doc_body = trimmed.strip_prefix("##").map(str::trim_start);
let plain_body = trimmed.strip_prefix('#').map(str::trim_start);
if let Some(body) = doc_body
&& let Some(value) = body.strip_prefix("@isolation").map(str::trim)
{
return match value {
"file" => Some(IsolationDirective::File),
"test" => Some(IsolationDirective::Test),
_ => None,
};
}
if let Some(body) = plain_body
&& let Some(value) = body
.strip_prefix("gdtest:")
.map(str::trim_start)
.and_then(|rest| rest.strip_prefix("isolation="))
.map(str::trim)
{
return match value {
"file" => Some(IsolationDirective::File),
"test" => Some(IsolationDirective::Test),
_ => None,
};
}
None
}
fn extract_tags_preceding(source: &[u8], func_row: usize) -> Vec<String> {
let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
let mut tags = Vec::new();
let mut row = func_row;
while row > 0 {
row -= 1;
let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
if line.is_empty() {
break;
}
if let Some(tag) = parse_tag_comment(line) {
tags.push(tag);
} else if line.starts_with('#') {
} else {
break;
}
}
tags.reverse();
tags
}
fn extract_isolation_preceding(source: &[u8], func_row: usize) -> Option<IsolationDirective> {
let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
let mut row = func_row;
while row > 0 {
row -= 1;
let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
if line.is_empty() {
break;
}
if let Some(isolation) = parse_isolation_comment(line) {
return Some(isolation);
}
if line.starts_with('#') {
continue;
}
break;
}
None
}
fn extract_file_isolation(source: &[u8]) -> Option<IsolationDirective> {
for raw_line in source.split(|&b| b == b'\n') {
let line = std::str::from_utf8(raw_line).unwrap_or("").trim();
if line.is_empty() {
continue;
}
if let Some(isolation) = parse_isolation_comment(line) {
return Some(isolation);
}
if line.starts_with('#') {
continue;
}
break;
}
None
}
fn classify_func(name: &str) -> Option<TestKind> {
if name.starts_with("bench_") {
Some(TestKind::Bench)
} else if name.starts_with("test_") {
Some(TestKind::Normal)
} else {
None
}
}
fn collect_test_entries(
tree: &gdsyntax::SyntaxTree,
members: impl Iterator<Item = Member>,
source: &[u8],
describe: Option<&str>,
file_isolation: Option<IsolationDirective>,
out: &mut Vec<TestEntry>,
) {
for member in members {
match member {
Member::Func(func) => {
let Some(name_token) = func.name() else {
continue;
};
let fname = name_token.text();
let Some(kind) = classify_func(fname) else {
continue;
};
let name = match describe {
Some(cls) => format!("{cls}/{fname}"),
None => fname.to_string(),
};
let row = tree
.position(func.syntax().text_range().start().into())
.line
- 1;
let tags = extract_tags_preceding(source, row);
let isolation = extract_isolation_preceding(source, row).or(file_isolation);
out.push(TestEntry {
name,
kind,
tags,
isolation,
});
}
Member::Class(class) if describe.is_none() => {
if let Some(name_token) = class.name()
&& name_token.text().starts_with("describe_")
{
let members: Vec<Member> = class
.body()
.map(|body| body.members().collect())
.unwrap_or_default();
collect_test_entries(
tree,
members.into_iter(),
source,
Some(name_token.text()),
file_isolation,
out,
);
}
}
_ => {}
}
}
}
pub fn list_tests_in(script_path: &Path) -> Result<Vec<String>> {
let entries = list_test_entries_in(script_path)?;
Ok(entries
.into_iter()
.filter(|e| e.kind == TestKind::Normal)
.map(|e| e.name)
.collect())
}
pub fn to_res_path(file: &Path, project_abs: &Path) -> Result<String> {
let rel = file.strip_prefix(project_abs).with_context(|| {
format!(
"{} is outside project {}",
file.display(),
project_abs.display()
)
})?;
let s = rel.to_string_lossy().replace('\\', "/");
Ok(format!("res://{s}"))
}
pub fn res_to_abs(res_path: &str, project_abs: &Path) -> Result<PathBuf> {
let rel = res_path
.strip_prefix("res://")
.ok_or_else(|| anyhow!("expected res:// prefix: {res_path}"))?;
Ok(project_abs.join(rel))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn make_temp_project(prefix: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("gdtest-{prefix}-{nonce}"));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn extracts_test_functions() {
let src = br#"
extends RefCounted
func _helper() -> void:
pass
func test_alpha() -> void:
pass
func test_beta(arg: int) -> void:
# func test_inside_comment() should NOT be picked up
pass
func not_a_test() -> void:
pass
# Async test
func test_gamma() -> void:
await physics_frame
"#;
let entries = list_test_entries_in_source(src).unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["test_alpha", "test_beta", "test_gamma"]);
assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
}
#[test]
fn unknown_prefixes_are_not_tests() {
let src = br#"
func test_normal() -> void:
pass
func xtest_skipped() -> void:
pass
func ftest_focused() -> void:
pass
func helper() -> void:
pass
"#;
let entries = list_test_entries_in_source(src).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "test_normal");
assert_eq!(entries[0].kind, TestKind::Normal);
}
#[test]
fn parses_tag_comments() {
let src = br#"
extends GdTest
## @tag slow
## @tag integration
func test_heavy() -> void:
pass
## regular comment, not a tag
func test_untagged() -> void:
pass
func test_also_untagged() -> void:
pass
"#;
let entries = list_test_entries_in_source(src).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].name, "test_heavy");
assert_eq!(entries[0].tags, vec!["slow", "integration"]);
assert!(entries[1].tags.is_empty());
assert!(entries[2].tags.is_empty());
}
#[test]
fn parses_file_header_isolation_directive() {
let src = br#"
# gdtest: isolation=test
extends GdTest
func test_one() -> void:
pass
func test_two() -> void:
pass
"#;
let entries = list_test_entries_in_source(src).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].isolation, Some(IsolationDirective::Test));
assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
}
#[test]
fn function_isolation_overrides_file_header() {
let src = br#"
## @isolation test
extends GdTest
## @isolation file
func test_one() -> void:
pass
func test_two() -> void:
pass
"#;
let entries = list_test_entries_in_source(src).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].isolation, Some(IsolationDirective::File));
assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
}
#[test]
fn classifies_bench_funcs() {
let src = br#"
func test_normal() -> void:
pass
func bench_pathfinding() -> void:
pass
"#;
let entries = list_test_entries_in_source(src).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].kind, TestKind::Normal);
assert_eq!(entries[1].kind, TestKind::Bench);
let runnable: Vec<String> = entries
.into_iter()
.filter(|e| e.kind == TestKind::Normal)
.map(|e| e.name)
.collect();
assert_eq!(runnable, vec!["test_normal"]);
}
#[test]
fn empty_on_parse_error_tolerates_partial() {
let src = br#"func test_recoverable(): pass
!@#$%garbage that breaks parsing"#;
let _entries = list_test_entries_in_source(src).unwrap();
}
#[test]
fn discovers_describe_class_tests() {
let src = br#"
extends GdTest
func test_top_level() -> void:
pass
class describe_Movement:
func test_moves_right() -> void:
pass
func test_moves_left() -> void:
pass
func helper_not_a_test() -> void:
pass
class IgnoredClass:
func test_inside_ignored() -> void:
pass
"#;
let entries = list_test_entries_in_source(src).unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(
names,
vec![
"test_top_level",
"describe_Movement/test_moves_right",
"describe_Movement/test_moves_left",
]
);
assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
}
#[test]
fn discovers_non_ascii_test_names() {
let src = "func test_日本語() -> void:\n\tpass\n".as_bytes();
let entries = list_test_entries_in_source(src).unwrap();
assert_eq!(entries[0].name, "test_日本語");
}
#[test]
fn reads_tags_above_annotations() {
let src = br#"
## @tag slow
@warning_ignore("unused_variable")
func test_annotated() -> void:
var x := 1
"#;
let entries = list_test_entries_in_source(src).unwrap();
assert_eq!(entries[0].tags, vec!["slow"]);
}
#[test]
fn finds_tests_after_a_syntax_error() {
let src = b"func broken(:\n\tpass\n\nfunc test_after() -> void:\n\tpass\n";
let entries = list_test_entries_in_source(src).unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["test_after"]);
}
#[test]
fn skips_classes_nested_in_describe_classes() {
let src = br#"
class describe_Outer:
func test_outer() -> void:
pass
class describe_Inner:
func test_inner() -> void:
pass
"#;
let entries = list_test_entries_in_source(src).unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["describe_Outer/test_outer"]);
}
#[test]
fn glob_match_test_dir_pattern() {
assert!(glob_match("test/**/*.gd", "test/battle.gd"));
assert!(glob_match("test/**/*.gd", "test/docs/test_foo.gd"));
assert!(glob_match("test/**/*.gd", "test/docs/dungeon/battle.gd"));
assert!(!glob_match("test/**/*.gd", "src/battle.gd"));
assert!(!glob_match("test/**/*.gd", "test/battle.tscn"));
}
#[test]
fn glob_match_colocated_pattern() {
assert!(glob_match("**/*.test.gd", "battle.test.gd"));
assert!(glob_match("**/*.test.gd", "src/battle/battle.test.gd"));
assert!(glob_match("**/*.test.gd", "deep/nested/dir/foo.test.gd"));
assert!(!glob_match("**/*.test.gd", "test_battle.gd"));
assert!(!glob_match("**/*.test.gd", "battle.gd"));
}
#[test]
fn glob_match_star_prefix_legacy() {
assert!(glob_match("test/test_*.gd", "test/test_battle.gd"));
assert!(!glob_match("test/test_*.gd", "test/battle.gd"));
}
#[test]
fn extract_describe_dir_finds_prefix() {
use std::path::PathBuf;
let p = PathBuf::from("test/describe_Battle/basic_hit.gd");
assert_eq!(
extract_describe_dir(&p),
Some("describe_Battle".to_string())
);
}
#[test]
fn extract_describe_dir_none_for_normal() {
use std::path::PathBuf;
let p = PathBuf::from("test/docs/test_foo.gd");
assert_eq!(extract_describe_dir(&p), None);
}
#[test]
fn extract_describe_dir_returns_last() {
use std::path::PathBuf;
let p = PathBuf::from("test/describe_Outer/describe_Inner/foo.gd");
assert_eq!(extract_describe_dir(&p), Some("describe_Inner".to_string()));
}
#[test]
fn resolve_excludes_discovery_globs_for_directory_walks() {
let project = make_temp_project("resolve-exclude-dir");
fs::create_dir_all(project.join("test/helpers")).unwrap();
fs::create_dir_all(project.join("test/cases")).unwrap();
fs::write(
project.join("test/helpers/helper.gd"),
"extends RefCounted\n",
)
.unwrap();
fs::write(
project.join("test/cases/test_ok.gd"),
"extends RefCounted\n",
)
.unwrap();
let inputs = vec!["test".to_string()];
let patterns = vec!["test/**/*.gd".to_string()];
let excludes = vec!["test/helpers/**".to_string()];
let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
assert_eq!(resolved, vec!["res://test/cases/test_ok.gd"]);
fs::remove_dir_all(project).ok();
}
#[test]
fn resolve_keeps_explicit_file_targets_even_if_excluded() {
let project = make_temp_project("resolve-explicit-file");
fs::create_dir_all(project.join("test/helpers")).unwrap();
let helper = project.join("test/helpers/helper.gd");
fs::write(&helper, "extends RefCounted\n").unwrap();
let inputs = vec![helper.to_string_lossy().into_owned()];
let patterns = vec!["test/**/*.gd".to_string()];
let excludes = vec!["test/helpers/**".to_string()];
let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
assert_eq!(resolved, vec!["res://test/helpers/helper.gd"]);
fs::remove_dir_all(project).ok();
}
}