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 include/exclude globs.
6//! 2. [`list_tests_in`] parses one GDScript file with gdsyntax
7//!    and returns every `func test_*()` name in source order.
8//!
9//! Default patterns:
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`:  `include = ["test/**/*.gd", "**/*.test.gd"]`
14//!
15//! When walking a folder, hidden folders (`.godot`, `.git`…), `addons`,
16//! folders with a `.gdignore`, and nested Godot projects are skipped, as the
17//! Godot editor does, so an addon's own tests never run as the project's.
18//! A folder or file named directly is always used.
19//!
20//! Target: discovering 500 tests across 50 files in <100 ms on a warm cache
21//!.
22
23/// Default discovery globs used when no `include` key is in `gdtest.toml`.
24pub const DEFAULT_PATTERNS: &[&str] = &["test/**/*.gd", "**/*.test.gd"];
25
26pub mod coverage;
27pub mod deps;
28pub mod gdunit4_migrate;
29pub mod gut_migrate;
30pub mod mutate;
31pub mod rewrite;
32pub use gdunit4_migrate::{
33    GdUnit4MigrationAction, GdUnit4MigrationIssue, GdUnit4MigrationOutput, GdUnit4MigrationStatus,
34    is_gdunit4_test_file, is_gdunit4_test_source, migrate_gdunit4_file, migrate_gdunit4_source,
35};
36pub use gut_migrate::{
37    GutMigrationAction, GutMigrationIssue, GutMigrationOutput, GutMigrationStatus,
38    is_gut_test_file, is_gut_test_source, migrate_gut_file, migrate_gut_source,
39};
40pub use rewrite::{RewriteOutput, rewrite_file, rewrite_source};
41
42use std::path::{Path, PathBuf};
43
44use anyhow::{Context, Result, anyhow};
45use gdsyntax::ast::{AstNode, Member};
46use walkdir::WalkDir;
47
48/// Normalise every input path into a list of `res://…` test-file paths.
49///
50/// `patterns` is a slice of glob strings (relative to project root) used when
51/// walking directories. `exclude_patterns` is a slice of discovery-exclude
52/// globs, also relative to project root. Pass [`DEFAULT_PATTERNS`] and `[]`
53/// when no custom config is set.
54pub fn resolve(
55    inputs: &[String],
56    project_path: &Path,
57    patterns: &[String],
58    exclude_patterns: &[String],
59) -> Result<Vec<String>> {
60    let project_abs = project_path
61        .canonicalize()
62        .with_context(|| format!("project path does not exist: {}", project_path.display()))?;
63    let mut out: Vec<String> = Vec::new();
64    for input in inputs {
65        if let Some(res) = input.strip_prefix("res://") {
66            out.push(format!("res://{res}"));
67            continue;
68        }
69        let path = PathBuf::from(input);
70        let abs = if path.is_absolute() {
71            path.clone()
72        } else {
73            project_abs.join(&path)
74        };
75        // Canonicalize so the \\?\ UNC prefix (Windows) matches project_abs.
76        let abs = abs.canonicalize().unwrap_or(abs);
77        if abs.is_file() {
78            if abs.extension().and_then(|e| e.to_str()) != Some("gd") {
79                return Err(anyhow!("not a .gd file: {}", abs.display()));
80            }
81            out.push(to_res_path(&abs, &project_abs)?);
82        } else if abs.is_dir() {
83            let walker = WalkDir::new(&abs)
84                .follow_links(false)
85                .into_iter()
86                .filter_entry(|entry| entry.depth() == 0 || !is_skipped_dir(entry));
87            for entry in walker {
88                let entry = entry.with_context(|| format!("walking {}", abs.display()))?;
89                if !entry.file_type().is_file() {
90                    continue;
91                }
92                if entry.path().extension().and_then(|e| e.to_str()) != Some("gd") {
93                    continue;
94                }
95                let ep = entry
96                    .path()
97                    .canonicalize()
98                    .unwrap_or_else(|_| entry.path().to_path_buf());
99                // Match against project-root-relative path with forward slashes.
100                let rel = ep.strip_prefix(&project_abs).unwrap_or(&ep);
101                let rel_str = rel.to_string_lossy().replace('\\', "/");
102                if !patterns.iter().any(|p| glob_match(p, &rel_str)) {
103                    continue;
104                }
105                if exclude_patterns.iter().any(|p| glob_match(p, &rel_str)) {
106                    continue;
107                }
108                out.push(to_res_path(&ep, &project_abs)?);
109            }
110        } else {
111            return Err(anyhow!("path not found: {}", abs.display()));
112        }
113    }
114    out.sort();
115    out.dedup();
116    Ok(out)
117}
118
119/// Folders a walk skips, as the Godot editor does: hidden ones, `addons`,
120/// those with a `.gdignore`, and nested Godot projects.
121fn is_skipped_dir(entry: &walkdir::DirEntry) -> bool {
122    if !entry.file_type().is_dir() {
123        return false;
124    }
125    let name = entry.file_name().to_string_lossy();
126    name.starts_with('.')
127        || name == "addons"
128        || entry.path().join(".gdignore").exists()
129        || entry.path().join("project.godot").exists()
130}
131
132/// Match a relative path (forward-slash separated) against a simple glob pattern.
133/// Supports `**` (zero or more path segments) and `*` (any chars within one segment).
134pub fn glob_match(pattern: &str, path: &str) -> bool {
135    let pat: Vec<&str> = pattern.split('/').collect();
136    let seg: Vec<&str> = path.split('/').collect();
137    glob_seg(&pat, &seg)
138}
139
140fn glob_seg(pat: &[&str], seg: &[&str]) -> bool {
141    if pat.is_empty() {
142        return seg.is_empty();
143    }
144    if pat[0] == "**" {
145        for i in 0..=seg.len() {
146            if glob_seg(&pat[1..], &seg[i..]) {
147                return true;
148            }
149        }
150        return false;
151    }
152    if seg.is_empty() {
153        return false;
154    }
155    if seg_matches(pat[0], seg[0]) {
156        return glob_seg(&pat[1..], &seg[1..]);
157    }
158    false
159}
160
161/// Match a single path segment against a pattern that may contain `*` wildcards.
162fn seg_matches(pattern: &str, s: &str) -> bool {
163    if !pattern.contains('*') {
164        return pattern == s;
165    }
166    let parts: Vec<&str> = pattern.split('*').collect();
167    let mut pos = 0usize;
168    for (i, part) in parts.iter().enumerate() {
169        if i == 0 {
170            if !s.starts_with(part) {
171                return false;
172            }
173            pos += part.len();
174        } else if i == parts.len() - 1 {
175            return s[pos..].ends_with(part);
176        } else {
177            match s[pos..].find(part) {
178                None => return false,
179                Some(idx) => pos += idx + part.len(),
180            }
181        }
182    }
183    true
184}
185
186/// If `path` contains a component named `describe_<Name>`, return that component name.
187/// Used to prefix test function names for directory-based sub-suites.
188pub fn extract_describe_dir(path: &Path) -> Option<String> {
189    path.components()
190        .filter_map(|c| match c {
191            std::path::Component::Normal(s) => s.to_str(),
192            _ => None,
193        })
194        .rfind(|s| s.starts_with("describe_"))
195        .map(|s| s.to_string())
196}
197
198/// Classification of a discovered test function.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum TestKind {
201    /// `test_*` — runs normally.
202    Normal,
203    /// `bench_*` — benchmark; only runs when `--bench` is passed.
204    Bench,
205}
206
207/// Per-test isolation override discovered from source comments.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum IsolationDirective {
210    File,
211    Test,
212}
213
214/// A discovered test function with its run classification.
215#[derive(Debug, Clone)]
216pub struct TestEntry {
217    pub name: String,
218    pub kind: TestKind,
219    /// Tags collected from `## @tag <name>` doc comments immediately preceding the function.
220    pub tags: Vec<String>,
221    /// Isolation override from source comments.
222    pub isolation: Option<IsolationDirective>,
223}
224
225/// Return every `func test_*()` and `func bench_*()` in the given GDScript file, in source order.
226///
227/// If the file lives inside a `describe_<Name>/` directory, all returned
228/// entry names are prefixed with `describe_<Name>/` to match the reporting
229/// format of inner-class describe blocks.
230pub fn list_test_entries_in(script_path: &Path) -> Result<Vec<TestEntry>> {
231    let source =
232        std::fs::read(script_path).with_context(|| format!("reading {}", script_path.display()))?;
233    let mut entries = list_test_entries_in_source(&source)?;
234    if let Some(prefix) = extract_describe_dir(script_path) {
235        for entry in &mut entries {
236            entry.name = format!("{prefix}/{}", entry.name);
237        }
238    }
239    Ok(entries)
240}
241
242pub fn list_test_entries_in_source(source: &[u8]) -> Result<Vec<TestEntry>> {
243    // Discovery works on lines, which invalid UTF-8 replaced by gdsyntax
244    // cannot move.
245    let tree = gdsyntax::parse_bytes(source);
246    let file_isolation = extract_file_isolation(source);
247    let mut out = Vec::new();
248    collect_test_entries(
249        &tree,
250        tree.file().members(),
251        source,
252        None,
253        file_isolation,
254        &mut out,
255    );
256    Ok(out)
257}
258
259/// Parse a `## @tag <name>` doc comment. Returns the tag name or `None`.
260fn parse_tag_comment(text: &str) -> Option<String> {
261    let body = text.trim().strip_prefix("##")?.trim_start();
262    let name = body.strip_prefix("@tag")?.trim();
263    if name.is_empty() || name.contains(char::is_whitespace) {
264        return None;
265    }
266    Some(name.to_string())
267}
268
269fn parse_isolation_comment(text: &str) -> Option<IsolationDirective> {
270    let trimmed = text.trim();
271    let doc_body = trimmed.strip_prefix("##").map(str::trim_start);
272    let plain_body = trimmed.strip_prefix('#').map(str::trim_start);
273
274    if let Some(body) = doc_body
275        && let Some(value) = body.strip_prefix("@isolation").map(str::trim)
276    {
277        return match value {
278            "file" => Some(IsolationDirective::File),
279            "test" => Some(IsolationDirective::Test),
280            _ => None,
281        };
282    }
283
284    if let Some(body) = plain_body
285        && let Some(value) = body
286            .strip_prefix("gdtest:")
287            .map(str::trim_start)
288            .and_then(|rest| rest.strip_prefix("isolation="))
289            .map(str::trim)
290    {
291        return match value {
292            "file" => Some(IsolationDirective::File),
293            "test" => Some(IsolationDirective::Test),
294            _ => None,
295        };
296    }
297
298    None
299}
300
301/// Scan source lines immediately before `func_row` and collect `## @tag` names.
302/// Stops at a blank line or any line that is neither a `## @tag` nor a plain `#` comment.
303fn extract_tags_preceding(source: &[u8], func_row: usize) -> Vec<String> {
304    let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
305    let mut tags = Vec::new();
306    let mut row = func_row;
307    while row > 0 {
308        row -= 1;
309        let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
310        if line.is_empty() {
311            break;
312        }
313        if let Some(tag) = parse_tag_comment(line) {
314            tags.push(tag);
315        } else if line.starts_with('#') {
316            // plain comment — keep scanning upward
317        } else {
318            break;
319        }
320    }
321    tags.reverse();
322    tags
323}
324
325fn extract_isolation_preceding(source: &[u8], func_row: usize) -> Option<IsolationDirective> {
326    let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
327    let mut row = func_row;
328    while row > 0 {
329        row -= 1;
330        let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
331        if line.is_empty() {
332            break;
333        }
334        if let Some(isolation) = parse_isolation_comment(line) {
335            return Some(isolation);
336        }
337        if line.starts_with('#') {
338            continue;
339        }
340        break;
341    }
342    None
343}
344
345fn extract_file_isolation(source: &[u8]) -> Option<IsolationDirective> {
346    for raw_line in source.split(|&b| b == b'\n') {
347        let line = std::str::from_utf8(raw_line).unwrap_or("").trim();
348        if line.is_empty() {
349            continue;
350        }
351        if let Some(isolation) = parse_isolation_comment(line) {
352            return Some(isolation);
353        }
354        if line.starts_with('#') {
355            continue;
356        }
357        break;
358    }
359    None
360}
361
362/// Classify a function name into its TestKind, or `None` if it's not a test.
363fn classify_func(name: &str) -> Option<TestKind> {
364    if name.starts_with("bench_") {
365        Some(TestKind::Bench)
366    } else if name.starts_with("test_") {
367        Some(TestKind::Normal)
368    } else {
369        None
370    }
371}
372
373/// Collect test entries from the members of a file or of a `describe_*` class.
374/// `describe` is the name of the enclosing `describe_*` inner class, if any.
375fn collect_test_entries(
376    tree: &gdsyntax::SyntaxTree,
377    members: impl Iterator<Item = Member>,
378    source: &[u8],
379    describe: Option<&str>,
380    file_isolation: Option<IsolationDirective>,
381    out: &mut Vec<TestEntry>,
382) {
383    for member in members {
384        match member {
385            Member::Func(func) => {
386                let Some(name_token) = func.name() else {
387                    continue;
388                };
389                let fname = name_token.text();
390                let Some(kind) = classify_func(fname) else {
391                    continue;
392                };
393                let name = match describe {
394                    Some(cls) => format!("{cls}/{fname}"),
395                    None => fname.to_string(),
396                };
397                // The node starts at the function's first annotation, so tags
398                // and directives are read from the lines above it.
399                let row = tree
400                    .position(func.syntax().text_range().start().into())
401                    .line
402                    - 1;
403                let tags = extract_tags_preceding(source, row);
404                let isolation = extract_isolation_preceding(source, row).or(file_isolation);
405                out.push(TestEntry {
406                    name,
407                    kind,
408                    tags,
409                    isolation,
410                });
411            }
412            // Only top-level `describe_*` inner classes hold tests; the worker
413            // instantiates them by name, so classes nested deeper are skipped.
414            Member::Class(class) if describe.is_none() => {
415                if let Some(name_token) = class.name()
416                    && name_token.text().starts_with("describe_")
417                {
418                    let members: Vec<Member> = class
419                        .body()
420                        .map(|body| body.members().collect())
421                        .unwrap_or_default();
422                    collect_test_entries(
423                        tree,
424                        members.into_iter(),
425                        source,
426                        Some(name_token.text()),
427                        file_isolation,
428                        out,
429                    );
430                }
431            }
432            _ => {}
433        }
434    }
435}
436
437/// Convenience wrapper: returns the names of tests that would actually run
438/// (excludes `bench_*`).
439pub fn list_tests_in(script_path: &Path) -> Result<Vec<String>> {
440    let entries = list_test_entries_in(script_path)?;
441    Ok(entries
442        .into_iter()
443        .filter(|e| e.kind == TestKind::Normal)
444        .map(|e| e.name)
445        .collect())
446}
447
448/// Translate an absolute on-disk path to the res:// form used by the
449/// worker. Caller guarantees `file` is inside `project_abs`.
450pub fn to_res_path(file: &Path, project_abs: &Path) -> Result<String> {
451    let rel = file.strip_prefix(project_abs).with_context(|| {
452        format!(
453            "{} is outside project {}",
454            file.display(),
455            project_abs.display()
456        )
457    })?;
458    let s = rel.to_string_lossy().replace('\\', "/");
459    Ok(format!("res://{s}"))
460}
461
462/// Translate a res:// path to its on-disk absolute form.
463pub fn res_to_abs(res_path: &str, project_abs: &Path) -> Result<PathBuf> {
464    let rel = res_path
465        .strip_prefix("res://")
466        .ok_or_else(|| anyhow!("expected res:// prefix: {res_path}"))?;
467    Ok(project_abs.join(rel))
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use std::fs;
474    use std::path::PathBuf;
475    use std::time::{SystemTime, UNIX_EPOCH};
476
477    fn make_temp_project(prefix: &str) -> PathBuf {
478        let nonce = SystemTime::now()
479            .duration_since(UNIX_EPOCH)
480            .unwrap()
481            .as_nanos();
482        let dir = std::env::temp_dir().join(format!("gdtest-{prefix}-{nonce}"));
483        fs::create_dir_all(&dir).unwrap();
484        dir
485    }
486
487    #[test]
488    fn extracts_test_functions() {
489        let src = br#"
490extends RefCounted
491
492func _helper() -> void:
493    pass
494
495func test_alpha() -> void:
496    pass
497
498func test_beta(arg: int) -> void:
499    # func test_inside_comment() should NOT be picked up
500    pass
501
502func not_a_test() -> void:
503    pass
504
505# Async test
506func test_gamma() -> void:
507    await physics_frame
508"#;
509        let entries = list_test_entries_in_source(src).unwrap();
510        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
511        assert_eq!(names, vec!["test_alpha", "test_beta", "test_gamma"]);
512        assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
513    }
514
515    #[test]
516    fn unknown_prefixes_are_not_tests() {
517        let src = br#"
518func test_normal() -> void:
519    pass
520func xtest_skipped() -> void:
521    pass
522func ftest_focused() -> void:
523    pass
524func helper() -> void:
525    pass
526"#;
527        let entries = list_test_entries_in_source(src).unwrap();
528        // xtest_* and ftest_* are not recognized — only test_* is.
529        assert_eq!(entries.len(), 1);
530        assert_eq!(entries[0].name, "test_normal");
531        assert_eq!(entries[0].kind, TestKind::Normal);
532    }
533
534    #[test]
535    fn parses_tag_comments() {
536        let src = br#"
537extends GdTest
538
539## @tag slow
540## @tag integration
541func test_heavy() -> void:
542    pass
543
544## regular comment, not a tag
545func test_untagged() -> void:
546    pass
547
548func test_also_untagged() -> void:
549    pass
550"#;
551        let entries = list_test_entries_in_source(src).unwrap();
552        assert_eq!(entries.len(), 3);
553        assert_eq!(entries[0].name, "test_heavy");
554        assert_eq!(entries[0].tags, vec!["slow", "integration"]);
555        assert!(entries[1].tags.is_empty());
556        assert!(entries[2].tags.is_empty());
557    }
558
559    #[test]
560    fn parses_file_header_isolation_directive() {
561        let src = br#"
562# gdtest: isolation=test
563extends GdTest
564
565func test_one() -> void:
566    pass
567
568func test_two() -> void:
569    pass
570"#;
571        let entries = list_test_entries_in_source(src).unwrap();
572        assert_eq!(entries.len(), 2);
573        assert_eq!(entries[0].isolation, Some(IsolationDirective::Test));
574        assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
575    }
576
577    #[test]
578    fn function_isolation_overrides_file_header() {
579        let src = br#"
580## @isolation test
581extends GdTest
582
583## @isolation file
584func test_one() -> void:
585    pass
586
587func test_two() -> void:
588    pass
589"#;
590        let entries = list_test_entries_in_source(src).unwrap();
591        assert_eq!(entries.len(), 2);
592        assert_eq!(entries[0].isolation, Some(IsolationDirective::File));
593        assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
594    }
595
596    #[test]
597    fn classifies_bench_funcs() {
598        let src = br#"
599func test_normal() -> void:
600    pass
601func bench_pathfinding() -> void:
602    pass
603"#;
604        let entries = list_test_entries_in_source(src).unwrap();
605        assert_eq!(entries.len(), 2);
606        assert_eq!(entries[0].kind, TestKind::Normal);
607        assert_eq!(entries[1].kind, TestKind::Bench);
608        // bench_* excluded from list_tests_in
609        let runnable: Vec<String> = entries
610            .into_iter()
611            .filter(|e| e.kind == TestKind::Normal)
612            .map(|e| e.name)
613            .collect();
614        assert_eq!(runnable, vec!["test_normal"]);
615    }
616
617    #[test]
618    fn empty_on_parse_error_tolerates_partial() {
619        let src = br#"func test_recoverable(): pass
620!@#$%garbage that breaks parsing"#;
621        let _entries = list_test_entries_in_source(src).unwrap();
622    }
623
624    #[test]
625    fn discovers_describe_class_tests() {
626        let src = br#"
627extends GdTest
628
629func test_top_level() -> void:
630    pass
631
632class describe_Movement:
633    func test_moves_right() -> void:
634        pass
635    func test_moves_left() -> void:
636        pass
637    func helper_not_a_test() -> void:
638        pass
639
640class IgnoredClass:
641    func test_inside_ignored() -> void:
642        pass
643"#;
644        let entries = list_test_entries_in_source(src).unwrap();
645        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
646        assert_eq!(
647            names,
648            vec![
649                "test_top_level",
650                "describe_Movement/test_moves_right",
651                "describe_Movement/test_moves_left",
652            ]
653        );
654        assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
655    }
656
657    #[test]
658    fn discovers_non_ascii_test_names() {
659        let src = "func test_日本語() -> void:\n\tpass\n".as_bytes();
660        let entries = list_test_entries_in_source(src).unwrap();
661        assert_eq!(entries[0].name, "test_日本語");
662    }
663
664    #[test]
665    fn reads_tags_above_annotations() {
666        let src = br#"
667## @tag slow
668@warning_ignore("unused_variable")
669func test_annotated() -> void:
670    var x := 1
671"#;
672        let entries = list_test_entries_in_source(src).unwrap();
673        assert_eq!(entries[0].tags, vec!["slow"]);
674    }
675
676    #[test]
677    fn finds_tests_after_a_syntax_error() {
678        let src = b"func broken(:\n\tpass\n\nfunc test_after() -> void:\n\tpass\n";
679        let entries = list_test_entries_in_source(src).unwrap();
680        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
681        assert_eq!(names, vec!["test_after"]);
682    }
683
684    #[test]
685    fn skips_classes_nested_in_describe_classes() {
686        let src = br#"
687class describe_Outer:
688    func test_outer() -> void:
689        pass
690    class describe_Inner:
691        func test_inner() -> void:
692            pass
693"#;
694        let entries = list_test_entries_in_source(src).unwrap();
695        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
696        assert_eq!(names, vec!["describe_Outer/test_outer"]);
697    }
698
699    // ── glob_match tests ──────────────────────────────────────────────────────
700
701    #[test]
702    fn glob_match_test_dir_pattern() {
703        assert!(glob_match("test/**/*.gd", "test/battle.gd"));
704        assert!(glob_match("test/**/*.gd", "test/docs/test_foo.gd"));
705        assert!(glob_match("test/**/*.gd", "test/docs/dungeon/battle.gd"));
706        assert!(!glob_match("test/**/*.gd", "src/battle.gd"));
707        assert!(!glob_match("test/**/*.gd", "test/battle.tscn"));
708    }
709
710    #[test]
711    fn glob_match_colocated_pattern() {
712        assert!(glob_match("**/*.test.gd", "battle.test.gd"));
713        assert!(glob_match("**/*.test.gd", "src/battle/battle.test.gd"));
714        assert!(glob_match("**/*.test.gd", "deep/nested/dir/foo.test.gd"));
715        assert!(!glob_match("**/*.test.gd", "test_battle.gd"));
716        assert!(!glob_match("**/*.test.gd", "battle.gd"));
717    }
718
719    #[test]
720    fn glob_match_star_prefix_legacy() {
721        // Backward-compat: users may set patterns = ["test/test_*.gd"]
722        assert!(glob_match("test/test_*.gd", "test/test_battle.gd"));
723        assert!(!glob_match("test/test_*.gd", "test/battle.gd"));
724    }
725
726    // ── extract_describe_dir tests ────────────────────────────────────────────
727
728    #[test]
729    fn extract_describe_dir_finds_prefix() {
730        use std::path::PathBuf;
731        let p = PathBuf::from("test/describe_Battle/basic_hit.gd");
732        assert_eq!(
733            extract_describe_dir(&p),
734            Some("describe_Battle".to_string())
735        );
736    }
737
738    #[test]
739    fn extract_describe_dir_none_for_normal() {
740        use std::path::PathBuf;
741        let p = PathBuf::from("test/docs/test_foo.gd");
742        assert_eq!(extract_describe_dir(&p), None);
743    }
744
745    #[test]
746    fn extract_describe_dir_returns_last() {
747        use std::path::PathBuf;
748        // Nested describe dirs: return innermost
749        let p = PathBuf::from("test/describe_Outer/describe_Inner/foo.gd");
750        assert_eq!(extract_describe_dir(&p), Some("describe_Inner".to_string()));
751    }
752
753    #[test]
754    fn resolve_excludes_discovery_globs_for_directory_walks() {
755        let project = make_temp_project("resolve-exclude-dir");
756        fs::create_dir_all(project.join("test/helpers")).unwrap();
757        fs::create_dir_all(project.join("test/cases")).unwrap();
758        fs::write(
759            project.join("test/helpers/helper.gd"),
760            "extends RefCounted\n",
761        )
762        .unwrap();
763        fs::write(
764            project.join("test/cases/test_ok.gd"),
765            "extends RefCounted\n",
766        )
767        .unwrap();
768
769        let inputs = vec!["test".to_string()];
770        let patterns = vec!["test/**/*.gd".to_string()];
771        let excludes = vec!["test/helpers/**".to_string()];
772        let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
773
774        assert_eq!(resolved, vec!["res://test/cases/test_ok.gd"]);
775
776        fs::remove_dir_all(project).ok();
777    }
778
779    #[test]
780    fn resolve_keeps_explicit_file_targets_even_if_excluded() {
781        let project = make_temp_project("resolve-explicit-file");
782        fs::create_dir_all(project.join("test/helpers")).unwrap();
783        let helper = project.join("test/helpers/helper.gd");
784        fs::write(&helper, "extends RefCounted\n").unwrap();
785
786        let inputs = vec![helper.to_string_lossy().into_owned()];
787        let patterns = vec!["test/**/*.gd".to_string()];
788        let excludes = vec!["test/helpers/**".to_string()];
789        let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
790
791        assert_eq!(resolved, vec!["res://test/helpers/helper.gd"]);
792
793        fs::remove_dir_all(project).ok();
794    }
795
796    #[test]
797    fn walking_skips_addons_hidden_ignored_and_nested_projects() {
798        let project = make_temp_project("resolve-skips");
799        for (path, content) in [
800            ("test/test_ok.gd", ""),
801            ("feature/thing.test.gd", ""),
802            ("addons/plugin/plugin.test.gd", ""),
803            ("addons/plugin/test/test_plugin.gd", ""),
804            (".godot/cache/old.test.gd", ""),
805            ("generated/.gdignore", ""),
806            ("generated/gen.test.gd", ""),
807            ("server/project.godot", ""),
808            ("server/net.test.gd", ""),
809        ] {
810            let path = project.join(path);
811            fs::create_dir_all(path.parent().unwrap()).unwrap();
812            fs::write(path, content).unwrap();
813        }
814        let patterns: Vec<String> = DEFAULT_PATTERNS.iter().map(|p| p.to_string()).collect();
815
816        let resolved = resolve(&[".".to_string()], &project, &patterns, &[]).unwrap();
817        assert_eq!(
818            resolved,
819            vec!["res://feature/thing.test.gd", "res://test/test_ok.gd"]
820        );
821
822        // A skipped folder named directly is walked.
823        let resolved = resolve(&["addons/plugin".to_string()], &project, &patterns, &[]).unwrap();
824        assert_eq!(resolved, vec!["res://addons/plugin/plugin.test.gd"]);
825
826        fs::remove_dir_all(project).ok();
827    }
828}