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 is_gdunit4_test_file, is_gdunit4_test_source, migrate_gdunit4_file, migrate_gdunit4_source,
29 GdUnit4MigrationAction, GdUnit4MigrationIssue, GdUnit4MigrationOutput, GdUnit4MigrationStatus,
30};
31pub use gut_migrate::{
32 is_gut_test_file, is_gut_test_source, migrate_gut_file, migrate_gut_source, GutMigrationAction,
33 GutMigrationIssue, GutMigrationOutput, GutMigrationStatus,
34};
35pub use rewrite::{rewrite_file, rewrite_source, RewriteOutput};
36
37use std::path::{Path, PathBuf};
38
39use anyhow::{anyhow, Context, Result};
40use tree_sitter::Parser;
41use walkdir::WalkDir;
42
43pub fn resolve(inputs: &[String], project_path: &Path, patterns: &[String]) -> Result<Vec<String>> {
48 let project_abs = project_path
49 .canonicalize()
50 .with_context(|| format!("project path does not exist: {}", project_path.display()))?;
51 let mut out: Vec<String> = Vec::new();
52 for input in inputs {
53 if let Some(res) = input.strip_prefix("res://") {
54 out.push(format!("res://{res}"));
55 continue;
56 }
57 let path = PathBuf::from(input);
58 let abs = if path.is_absolute() {
59 path.clone()
60 } else {
61 project_abs.join(&path)
62 };
63 let abs = abs.canonicalize().unwrap_or(abs);
65 if abs.is_file() {
66 if abs.extension().and_then(|e| e.to_str()) != Some("gd") {
67 return Err(anyhow!("not a .gd file: {}", abs.display()));
68 }
69 out.push(to_res_path(&abs, &project_abs)?);
70 } else if abs.is_dir() {
71 for entry in WalkDir::new(&abs).follow_links(false) {
72 let entry = entry.with_context(|| format!("walking {}", abs.display()))?;
73 if !entry.file_type().is_file() {
74 continue;
75 }
76 if entry.path().extension().and_then(|e| e.to_str()) != Some("gd") {
77 continue;
78 }
79 let ep = entry
80 .path()
81 .canonicalize()
82 .unwrap_or_else(|_| entry.path().to_path_buf());
83 let rel = ep.strip_prefix(&project_abs).unwrap_or(&ep);
85 let rel_str = rel.to_string_lossy().replace('\\', "/");
86 if !patterns.iter().any(|p| glob_match(p, &rel_str)) {
87 continue;
88 }
89 out.push(to_res_path(&ep, &project_abs)?);
90 }
91 } else {
92 return Err(anyhow!("path not found: {}", abs.display()));
93 }
94 }
95 out.sort();
96 out.dedup();
97 Ok(out)
98}
99
100pub fn glob_match(pattern: &str, path: &str) -> bool {
103 let pat: Vec<&str> = pattern.split('/').collect();
104 let seg: Vec<&str> = path.split('/').collect();
105 glob_seg(&pat, &seg)
106}
107
108fn glob_seg(pat: &[&str], seg: &[&str]) -> bool {
109 if pat.is_empty() {
110 return seg.is_empty();
111 }
112 if pat[0] == "**" {
113 for i in 0..=seg.len() {
114 if glob_seg(&pat[1..], &seg[i..]) {
115 return true;
116 }
117 }
118 return false;
119 }
120 if seg.is_empty() {
121 return false;
122 }
123 if seg_matches(pat[0], seg[0]) {
124 return glob_seg(&pat[1..], &seg[1..]);
125 }
126 false
127}
128
129fn seg_matches(pattern: &str, s: &str) -> bool {
131 if !pattern.contains('*') {
132 return pattern == s;
133 }
134 let parts: Vec<&str> = pattern.split('*').collect();
135 let mut pos = 0usize;
136 for (i, part) in parts.iter().enumerate() {
137 if i == 0 {
138 if !s.starts_with(part) {
139 return false;
140 }
141 pos += part.len();
142 } else if i == parts.len() - 1 {
143 return s[pos..].ends_with(part);
144 } else {
145 match s[pos..].find(part) {
146 None => return false,
147 Some(idx) => pos += idx + part.len(),
148 }
149 }
150 }
151 true
152}
153
154pub fn extract_describe_dir(path: &Path) -> Option<String> {
157 path.components()
158 .filter_map(|c| match c {
159 std::path::Component::Normal(s) => s.to_str(),
160 _ => None,
161 })
162 .filter(|s| s.starts_with("describe_"))
163 .last()
164 .map(|s| s.to_string())
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum TestKind {
170 Normal,
172 Bench,
174}
175
176#[derive(Debug, Clone)]
178pub struct TestEntry {
179 pub name: String,
180 pub kind: TestKind,
181 pub tags: Vec<String>,
183}
184
185pub fn list_test_entries_in(script_path: &Path) -> Result<Vec<TestEntry>> {
191 let source =
192 std::fs::read(script_path).with_context(|| format!("reading {}", script_path.display()))?;
193 let mut entries = list_test_entries_in_source(&source)?;
194 if let Some(prefix) = extract_describe_dir(script_path) {
195 for entry in &mut entries {
196 entry.name = format!("{prefix}/{}", entry.name);
197 }
198 }
199 Ok(entries)
200}
201
202pub fn list_test_entries_in_source(source: &[u8]) -> Result<Vec<TestEntry>> {
203 let mut parser = Parser::new();
204 parser
205 .set_language(&tree_sitter_gdscript::LANGUAGE.into())
206 .context("failed to set GDScript language")?;
207 let Some(tree) = parser.parse(source, None) else {
208 return Ok(Vec::new());
209 };
210 Ok(collect_test_entries(tree.root_node(), source, None))
211}
212
213fn parse_tag_comment(text: &str) -> Option<String> {
215 let body = text.trim().strip_prefix("##")?.trim_start();
216 let name = body.strip_prefix("@tag")?.trim();
217 if name.is_empty() || name.contains(char::is_whitespace) {
218 return None;
219 }
220 Some(name.to_string())
221}
222
223fn extract_tags_preceding(source: &[u8], func_row: usize) -> Vec<String> {
226 let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
227 let mut tags = Vec::new();
228 let mut row = func_row;
229 while row > 0 {
230 row -= 1;
231 let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
232 if line.is_empty() {
233 break;
234 }
235 if let Some(tag) = parse_tag_comment(line) {
236 tags.push(tag);
237 } else if line.starts_with('#') {
238 } else {
240 break;
241 }
242 }
243 tags.reverse();
244 tags
245}
246
247fn classify_func(name: &str) -> Option<TestKind> {
249 if name.starts_with("bench_") {
250 Some(TestKind::Bench)
251 } else if name.starts_with("test_") {
252 Some(TestKind::Normal)
253 } else {
254 None
255 }
256}
257
258fn collect_test_entries<'a>(
261 node: tree_sitter::Node<'a>,
262 source: &[u8],
263 describe: Option<&str>,
264) -> Vec<TestEntry> {
265 let mut out = Vec::new();
266 for i in 0..node.child_count() {
267 let Some(child) = node.child(i as u32) else {
268 continue;
269 };
270 match child.kind() {
271 "function_definition" => {
272 if let Some(name_node) = child.child_by_field_name("name") {
273 if let Ok(fname) = name_node.utf8_text(source) {
274 if let Some(kind) = classify_func(fname) {
275 let name = match describe {
276 Some(cls) => format!("{cls}/{fname}"),
277 None => fname.to_string(),
278 };
279 let tags = extract_tags_preceding(source, child.start_position().row);
280 out.push(TestEntry { name, kind, tags });
281 }
282 }
283 }
284 }
286 "class_definition" if describe.is_none() => {
287 if let Some(name_node) = child.child_by_field_name("name") {
289 if let Ok(cname) = name_node.utf8_text(source) {
290 if cname.starts_with("describe_") {
291 out.extend(collect_test_entries(child, source, Some(cname)));
292 }
293 }
295 }
296 }
297 _ => {
298 out.extend(collect_test_entries(child, source, describe));
299 }
300 }
301 }
302 out
303}
304
305pub fn list_tests_in(script_path: &Path) -> Result<Vec<String>> {
308 let entries = list_test_entries_in(script_path)?;
309 Ok(entries
310 .into_iter()
311 .filter(|e| e.kind == TestKind::Normal)
312 .map(|e| e.name)
313 .collect())
314}
315
316pub fn to_res_path(file: &Path, project_abs: &Path) -> Result<String> {
319 let rel = file.strip_prefix(project_abs).with_context(|| {
320 format!(
321 "{} is outside project {}",
322 file.display(),
323 project_abs.display()
324 )
325 })?;
326 let s = rel.to_string_lossy().replace('\\', "/");
327 Ok(format!("res://{s}"))
328}
329
330pub fn res_to_abs(res_path: &str, project_abs: &Path) -> Result<PathBuf> {
332 let rel = res_path
333 .strip_prefix("res://")
334 .ok_or_else(|| anyhow!("expected res:// prefix: {res_path}"))?;
335 Ok(project_abs.join(rel))
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn extracts_test_functions() {
344 let src = br#"
345extends RefCounted
346
347func _helper() -> void:
348 pass
349
350func test_alpha() -> void:
351 pass
352
353func test_beta(arg: int) -> void:
354 # func test_inside_comment() should NOT be picked up
355 pass
356
357func not_a_test() -> void:
358 pass
359
360# Async test
361func test_gamma() -> void:
362 await physics_frame
363"#;
364 let entries = list_test_entries_in_source(src).unwrap();
365 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
366 assert_eq!(names, vec!["test_alpha", "test_beta", "test_gamma"]);
367 assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
368 }
369
370 #[test]
371 fn unknown_prefixes_are_not_tests() {
372 let src = br#"
373func test_normal() -> void:
374 pass
375func xtest_skipped() -> void:
376 pass
377func ftest_focused() -> void:
378 pass
379func helper() -> void:
380 pass
381"#;
382 let entries = list_test_entries_in_source(src).unwrap();
383 assert_eq!(entries.len(), 1);
385 assert_eq!(entries[0].name, "test_normal");
386 assert_eq!(entries[0].kind, TestKind::Normal);
387 }
388
389 #[test]
390 fn parses_tag_comments() {
391 let src = br#"
392extends GdTest
393
394## @tag slow
395## @tag integration
396func test_heavy() -> void:
397 pass
398
399## regular comment, not a tag
400func test_untagged() -> void:
401 pass
402
403func test_also_untagged() -> void:
404 pass
405"#;
406 let entries = list_test_entries_in_source(src).unwrap();
407 assert_eq!(entries.len(), 3);
408 assert_eq!(entries[0].name, "test_heavy");
409 assert_eq!(entries[0].tags, vec!["slow", "integration"]);
410 assert!(entries[1].tags.is_empty());
411 assert!(entries[2].tags.is_empty());
412 }
413
414 #[test]
415 fn classifies_bench_funcs() {
416 let src = br#"
417func test_normal() -> void:
418 pass
419func bench_pathfinding() -> void:
420 pass
421"#;
422 let entries = list_test_entries_in_source(src).unwrap();
423 assert_eq!(entries.len(), 2);
424 assert_eq!(entries[0].kind, TestKind::Normal);
425 assert_eq!(entries[1].kind, TestKind::Bench);
426 let runnable: Vec<String> = entries
428 .into_iter()
429 .filter(|e| e.kind == TestKind::Normal)
430 .map(|e| e.name)
431 .collect();
432 assert_eq!(runnable, vec!["test_normal"]);
433 }
434
435 #[test]
436 fn empty_on_parse_error_tolerates_partial() {
437 let src = br#"func test_recoverable(): pass
438!@#$%garbage that breaks parsing"#;
439 let _entries = list_test_entries_in_source(src).unwrap();
440 }
441
442 #[test]
443 fn discovers_describe_class_tests() {
444 let src = br#"
445extends GdTest
446
447func test_top_level() -> void:
448 pass
449
450class describe_Movement:
451 func test_moves_right() -> void:
452 pass
453 func test_moves_left() -> void:
454 pass
455 func helper_not_a_test() -> void:
456 pass
457
458class IgnoredClass:
459 func test_inside_ignored() -> void:
460 pass
461"#;
462 let entries = list_test_entries_in_source(src).unwrap();
463 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
464 assert_eq!(
465 names,
466 vec![
467 "test_top_level",
468 "describe_Movement/test_moves_right",
469 "describe_Movement/test_moves_left",
470 ]
471 );
472 assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
473 }
474
475 #[test]
478 fn glob_match_test_dir_pattern() {
479 assert!(glob_match("test/**/*.gd", "test/battle.gd"));
480 assert!(glob_match("test/**/*.gd", "test/docs/test_foo.gd"));
481 assert!(glob_match("test/**/*.gd", "test/docs/dungeon/battle.gd"));
482 assert!(!glob_match("test/**/*.gd", "src/battle.gd"));
483 assert!(!glob_match("test/**/*.gd", "test/battle.tscn"));
484 }
485
486 #[test]
487 fn glob_match_colocated_pattern() {
488 assert!(glob_match("**/*.test.gd", "battle.test.gd"));
489 assert!(glob_match("**/*.test.gd", "src/battle/battle.test.gd"));
490 assert!(glob_match("**/*.test.gd", "deep/nested/dir/foo.test.gd"));
491 assert!(!glob_match("**/*.test.gd", "test_battle.gd"));
492 assert!(!glob_match("**/*.test.gd", "battle.gd"));
493 }
494
495 #[test]
496 fn glob_match_star_prefix_legacy() {
497 assert!(glob_match("test/test_*.gd", "test/test_battle.gd"));
499 assert!(!glob_match("test/test_*.gd", "test/battle.gd"));
500 }
501
502 #[test]
505 fn extract_describe_dir_finds_prefix() {
506 use std::path::PathBuf;
507 let p = PathBuf::from("test/describe_Battle/basic_hit.gd");
508 assert_eq!(
509 extract_describe_dir(&p),
510 Some("describe_Battle".to_string())
511 );
512 }
513
514 #[test]
515 fn extract_describe_dir_none_for_normal() {
516 use std::path::PathBuf;
517 let p = PathBuf::from("test/docs/test_foo.gd");
518 assert_eq!(extract_describe_dir(&p), None);
519 }
520
521 #[test]
522 fn extract_describe_dir_returns_last() {
523 use std::path::PathBuf;
524 let p = PathBuf::from("test/describe_Outer/describe_Inner/foo.gd");
526 assert_eq!(extract_describe_dir(&p), Some("describe_Inner".to_string()));
527 }
528}