Skip to main content

gdtest_analysis/
lib.rs

1//! Discovery helpers for gdtest.
2//!
3//! Two layers:
4//! 1. [`resolve`] turns user-supplied paths (files, directories, `res://…`)
5//!    into a flat list of `res://…` paths, filtered by discovery patterns.
6//! 2. [`list_tests_in`] parses one GDScript file with tree-sitter-gdscript
7//!    and returns every `func test_*()` name in source order.
8//!
9//! Default patterns (MOB-169):
10//!   - `test/**/*.gd`  — any `.gd` file inside a `test/` directory tree
11//!   - `**/*.test.gd`  — co-located test files anywhere in the project
12//!
13//! Users can override via `gdtest.toml`:  `patterns = ["test/**/*.gd", "**/*.test.gd"]`
14//!
15//! Target: discovering 500 tests across 50 files in <100 ms on a warm cache
16//! (MOB-76 success criterion).
17
18/// Default discovery patterns used when no `patterns` key is in `gdtest.toml`.
19pub 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
43/// Normalise every input path into a list of `res://…` test-file paths.
44///
45/// `patterns` is a slice of glob strings (relative to project root) used when
46/// walking directories. Pass [`DEFAULT_PATTERNS`] when no custom config is set.
47pub 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        // Canonicalize so the \\?\ UNC prefix (Windows) matches project_abs.
64        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                // Match against project-root-relative path with forward slashes.
84                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
100/// Match a relative path (forward-slash separated) against a simple glob pattern.
101/// Supports `**` (zero or more path segments) and `*` (any chars within one segment).
102pub 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
129/// Match a single path segment against a pattern that may contain `*` wildcards.
130fn 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
154/// If `path` contains a component named `describe_<Name>`, return that component name.
155/// Used to prefix test function names for directory-based sub-suites (MOB-169).
156pub 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/// Classification of a discovered test function.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum TestKind {
170    /// `test_*` — runs normally.
171    Normal,
172    /// `bench_*` — benchmark; only runs when `--bench` is passed.
173    Bench,
174}
175
176/// A discovered test function with its run classification.
177#[derive(Debug, Clone)]
178pub struct TestEntry {
179    pub name: String,
180    pub kind: TestKind,
181    /// Tags collected from `## @tag <name>` doc comments immediately preceding the function.
182    pub tags: Vec<String>,
183}
184
185/// Return every `func test_*()` and `func bench_*()` in the given GDScript file, in source order.
186///
187/// If the file lives inside a `describe_<Name>/` directory, all returned
188/// entry names are prefixed with `describe_<Name>/` to match the reporting
189/// format of inner-class describe blocks (MOB-169).
190pub 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
213/// Parse a `## @tag <name>` doc comment. Returns the tag name or `None`.
214fn 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
223/// Scan source lines immediately before `func_row` and collect `## @tag` names.
224/// Stops at a blank line or any line that is neither a `## @tag` nor a plain `#` comment.
225fn 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            // plain comment — keep scanning upward
239        } else {
240            break;
241        }
242    }
243    tags.reverse();
244    tags
245}
246
247/// Classify a function name into its TestKind, or `None` if it's not a test.
248fn 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
258/// Recursively walk the AST to collect test entries.
259/// `describe` is the name of the enclosing `describe_*` inner class, if any.
260fn 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                // Never descend into function bodies — no nested functions in GDScript.
285            }
286            "class_definition" if describe.is_none() => {
287                // Only look inside top-level `describe_*` inner classes.
288                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                        // Non-describe inner classes are ignored entirely.
294                    }
295                }
296            }
297            _ => {
298                out.extend(collect_test_entries(child, source, describe));
299            }
300        }
301    }
302    out
303}
304
305/// Convenience wrapper: returns the names of tests that would actually run
306/// (excludes `bench_*`).
307pub 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
316/// Translate an absolute on-disk path to the res:// form used by the
317/// worker. Caller guarantees `file` is inside `project_abs`.
318pub 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
330/// Translate a res:// path to its on-disk absolute form.
331pub 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        // xtest_* and ftest_* are not recognized — only test_* is.
384        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        // bench_* excluded from list_tests_in
427        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    // ── glob_match tests ──────────────────────────────────────────────────────
476
477    #[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        // Backward-compat: users may set patterns = ["test/test_*.gd"]
498        assert!(glob_match("test/test_*.gd", "test/test_battle.gd"));
499        assert!(!glob_match("test/test_*.gd", "test/battle.gd"));
500    }
501
502    // ── extract_describe_dir tests ────────────────────────────────────────────
503
504    #[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        // Nested describe dirs: return innermost
525        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}