1use std::collections::{HashMap, HashSet};
7use std::path::Path;
8
9use walkdir::WalkDir;
10
11pub type DepGraph = HashMap<String, HashSet<String>>;
13
14pub 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 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
41fn 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 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
71pub 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
114pub 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}