Skip to main content

gdtest_analysis/
deps.rs

1//! GDScript dependency graph for test impact analysis (MOB-151).
2//!
3//! Parses `extends`, `preload()`, and `load()` calls to build a reverse
4//! dependency map: changed_file → test files that transitively import it.
5
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8
9use walkdir::WalkDir;
10
11/// Maps res:// path → set of res:// paths.
12pub type DepGraph = HashMap<String, HashSet<String>>;
13
14/// Parse all `res://` dependencies referenced in a GDScript source file.
15/// Handles `extends "res://..."`, `preload("res://...")`, `load("res://...")`.
16pub fn parse_file_deps(source: &[u8]) -> Vec<String> {
17    let text = match std::str::from_utf8(source) {
18        Ok(s) => s,
19        Err(_) => return Vec::new(),
20    };
21    let mut deps = Vec::new();
22    for line in text.lines() {
23        let trimmed = line.trim();
24        if trimmed.starts_with('#') {
25            continue;
26        }
27        if let Some(path) = extract_res_path(line, "preload(") {
28            deps.push(path);
29        }
30        // load() — skip positions that are actually part of preload()
31        if let Some(path) = extract_standalone_load(line) {
32            deps.push(path);
33        }
34        if let Some(path) = extract_res_path(line, "extends ") {
35            deps.push(path);
36        }
37    }
38    deps
39}
40
41/// Find `load("res://...")` on the line, skipping any occurrence that is
42/// actually the `load(` part of `preload(`.
43fn extract_standalone_load(line: &str) -> Option<String> {
44    let mut offset = 0;
45    while let Some(pos) = line[offset..].find("load(") {
46        let abs_pos = offset + pos;
47        offset = abs_pos + 5;
48        // Skip if preceded by "pre"
49        if abs_pos >= 3 && &line[abs_pos - 3..abs_pos] == "pre" {
50            continue;
51        }
52        return extract_res_path(&line[abs_pos..], "load(");
53    }
54    None
55}
56
57fn extract_res_path(line: &str, keyword: &str) -> Option<String> {
58    let kw_pos = line.find(keyword)?;
59    let after_kw = &line[kw_pos + keyword.len()..];
60    let (q_idx, q_char) = after_kw
61        .char_indices()
62        .find(|(_, c)| *c == '"' || *c == '\'')?;
63    let path_start = &after_kw[q_idx + q_char.len_utf8()..];
64    if !path_start.starts_with("res://") {
65        return None;
66    }
67    let end = path_start.find(q_char)?;
68    Some(path_start[..end].to_string())
69}
70
71/// Build forward and reverse dependency graphs by scanning all `.gd` files.
72///
73/// - `forward[A]` = files A imports
74/// - `reverse[B]` = files that import B
75pub fn build_dep_graph(project_abs: &Path) -> (DepGraph, DepGraph) {
76    let mut forward: DepGraph = HashMap::new();
77    let mut reverse: DepGraph = HashMap::new();
78
79    for entry in WalkDir::new(project_abs).follow_links(false) {
80        let Ok(entry) = entry else { continue };
81        if !entry.file_type().is_file() {
82            continue;
83        }
84        let path = entry.path();
85        if path.extension().and_then(|e| e.to_str()) != Some("gd") {
86            continue;
87        }
88        let Ok(rel) = path.strip_prefix(project_abs) else {
89            continue;
90        };
91        let rel_str = rel.to_string_lossy().replace('\\', "/");
92        if rel_str.starts_with(".godot/") {
93            continue;
94        }
95        let res_path = format!("res://{rel_str}");
96        forward.entry(res_path.clone()).or_default();
97        reverse.entry(res_path.clone()).or_default();
98
99        let Ok(source) = std::fs::read(path) else {
100            continue;
101        };
102        for dep in parse_file_deps(&source) {
103            forward
104                .entry(res_path.clone())
105                .or_default()
106                .insert(dep.clone());
107            reverse.entry(dep).or_default().insert(res_path.clone());
108        }
109    }
110
111    (forward, reverse)
112}
113
114/// Return all res:// paths that transitively depend on any file in `changed`.
115/// The result does NOT include the changed files themselves — only their dependents.
116pub fn affected_files(changed: &HashSet<String>, reverse: &DepGraph) -> HashSet<String> {
117    let mut affected: HashSet<String> = HashSet::new();
118    let mut frontier: Vec<String> = changed.iter().cloned().collect();
119
120    while let Some(file) = frontier.pop() {
121        if let Some(dependents) = reverse.get(&file) {
122            for dep in dependents {
123                if affected.insert(dep.clone()) {
124                    frontier.push(dep.clone());
125                }
126            }
127        }
128    }
129
130    affected
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn parses_preload_and_extends() {
139        let src = br#"extends "res://src/base.gd"
140const Util = preload("res://src/util.gd")
141var x = load("res://data/config.gd")
142"#;
143        let deps = parse_file_deps(src);
144        assert!(deps.contains(&"res://src/base.gd".to_string()), "extends");
145        assert!(deps.contains(&"res://src/util.gd".to_string()), "preload");
146        assert!(deps.contains(&"res://data/config.gd".to_string()), "load");
147    }
148
149    #[test]
150    fn skips_comments_and_non_res() {
151        let src = br#"# preload("res://commented.gd")
152load("user://settings.cfg")
153preload("res://valid.gd")
154"#;
155        let deps = parse_file_deps(src);
156        assert_eq!(deps, vec!["res://valid.gd".to_string()]);
157    }
158
159    #[test]
160    fn affected_files_transitive() {
161        let mut reverse: DepGraph = HashMap::new();
162        reverse.insert("res://a.gd".to_string(), ["res://b.gd".to_string()].into());
163        reverse.insert("res://b.gd".to_string(), ["res://c.gd".to_string()].into());
164
165        let changed: HashSet<String> = ["res://a.gd".to_string()].into();
166        let affected = affected_files(&changed, &reverse);
167        assert!(affected.contains("res://b.gd"), "direct dependent");
168        assert!(affected.contains("res://c.gd"), "transitive dependent");
169        assert!(
170            !affected.contains("res://a.gd"),
171            "changed file itself not in affected"
172        );
173    }
174}