1use std::path::{Path, PathBuf};
2
3const MAX_FILES_PER_NAME: usize = 8;
7
8use once_cell::sync::Lazy;
9use regex::Regex;
10use rustc_hash::{FxHashMap, FxHashSet};
11
12use crate::config::edge_weights::PYTHON_SEMANTIC;
13use crate::config::extensions::PYTHON_EXTENSIONS;
14use crate::config::weights::LANG_WEIGHTS;
15use crate::types::{Fragment, FragmentId};
16
17use super::super::EdgeDict;
18use super::super::base::{self, EdgeBuilder, path_to_module};
19
20fn is_python_file(path: &Path) -> bool {
21 let ext = base::file_ext(path);
22 PYTHON_EXTENSIONS.contains(ext.as_str())
23}
24
25static IMPORT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^\s*import\s+([\w.]+)").unwrap());
26static FROM_IMPORT_RE: Lazy<Regex> =
27 Lazy::new(|| Regex::new(r"(?m)^\s*from\s+([\w.]+)\s+import\s+(.+)").unwrap());
28static CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Za-z_]\w*)\s*\(").unwrap());
29static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Z]\w*)\b").unwrap());
30static DEF_RE: Lazy<Regex> =
31 Lazy::new(|| Regex::new(r"(?m)^\s*(?:def|class|async\s+def)\s+([A-Za-z_]\w*)").unwrap());
32
33fn extract_imports(content: &str, path: &Path, repo_root: Option<&Path>) -> FxHashSet<String> {
34 let mut imports = FxHashSet::default();
35 for cap in IMPORT_RE.captures_iter(content) {
36 imports.insert(cap[1].to_string());
37 }
38 for cap in FROM_IMPORT_RE.captures_iter(content) {
39 let module = &cap[1];
40 if module.starts_with('.') {
41 if let Some(parent) = path.parent() {
42 let module_path = path_to_module(parent, repo_root);
43 if !module_path.is_empty() {
44 imports.insert(module_path);
45 }
46 }
47 } else {
48 imports.insert(module.to_string());
49 let parts: Vec<&str> = module.split('.').collect();
50 for i in 1..parts.len() {
51 imports.insert(parts[..i].join("."));
52 }
53 }
54 }
55 imports
56}
57
58fn extract_defines(content: &str) -> FxHashSet<String> {
59 DEF_RE
60 .captures_iter(content)
61 .map(|c| c[1].to_string())
62 .collect()
63}
64
65fn extract_calls(content: &str) -> FxHashSet<String> {
66 CALL_RE
67 .captures_iter(content)
68 .map(|c| c[1].to_string())
69 .filter(|n| !PY_KEYWORDS.contains(n.as_str()))
70 .collect()
71}
72
73fn extract_type_refs(content: &str) -> FxHashSet<String> {
74 TYPE_REF_RE
75 .captures_iter(content)
76 .map(|c| c[1].to_string())
77 .collect()
78}
79
80static PY_KEYWORDS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
81 [
82 "if",
83 "for",
84 "while",
85 "return",
86 "def",
87 "class",
88 "import",
89 "from",
90 "as",
91 "with",
92 "try",
93 "except",
94 "finally",
95 "raise",
96 "pass",
97 "break",
98 "continue",
99 "yield",
100 "lambda",
101 "assert",
102 "del",
103 "elif",
104 "else",
105 "global",
106 "nonlocal",
107 "and",
108 "or",
109 "not",
110 "is",
111 "in",
112 "async",
113 "await",
114 "True",
115 "False",
116 "None",
117 "print",
118 "len",
119 "range",
120 "type",
121 "list",
122 "dict",
123 "set",
124 "tuple",
125 "str",
126 "int",
127 "float",
128 "bool",
129 "super",
130 "isinstance",
131 "hasattr",
132 "getattr",
133 "setattr",
134 "property",
135 "staticmethod",
136 "classmethod",
137 ]
138 .iter()
139 .copied()
140 .collect()
141});
142
143pub struct PythonEdgeBuilder;
144
145impl EdgeBuilder for PythonEdgeBuilder {
146 fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict {
147 let py_frags: Vec<&Fragment> = fragments
148 .iter()
149 .filter(|f| is_python_file(Path::new(f.path())))
150 .collect();
151 if py_frags.is_empty() {
152 return FxHashMap::default();
153 }
154
155 let weights = LANG_WEIGHTS.get("python").expect("python weights");
156 let call_weight = weights.call;
157 let symbol_ref_weight = weights.symbol_ref;
158 let type_ref_weight = weights.type_ref;
159
160 let mut name_to_defs: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
161 let mut name_def_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
162 let mut frag_defines: FxHashMap<FragmentId, FxHashSet<String>> = FxHashMap::default();
163 let mut module_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
164
165 for f in &py_frags {
166 let defines = extract_defines(&f.content);
167 for name in &defines {
168 name_to_defs
169 .entry(name.clone())
170 .or_default()
171 .push(f.id.clone());
172 name_def_files
173 .entry(name.clone())
174 .or_default()
175 .insert(f.path());
176 }
177 frag_defines.insert(f.id.clone(), defines);
178
179 let module = path_to_module(Path::new(f.path()), repo_root);
180 if !module.is_empty() {
181 module_to_frags
182 .entry(module)
183 .or_default()
184 .push(f.id.clone());
185 }
186 }
187
188 let frag_imports: FxHashMap<FragmentId, FxHashSet<String>> = py_frags
189 .iter()
190 .map(|f| {
191 let imports = extract_imports(&f.content, Path::new(f.path()), repo_root);
192 (f.id.clone(), imports)
193 })
194 .collect();
195
196 let frag_to_module: FxHashMap<FragmentId, String> = py_frags
197 .iter()
198 .filter_map(|f| {
199 let m = path_to_module(Path::new(f.path()), repo_root);
200 if m.is_empty() {
201 None
202 } else {
203 Some((f.id.clone(), m))
204 }
205 })
206 .collect();
207
208 let mut edges: EdgeDict = FxHashMap::default();
209
210 for (i, f) in py_frags.iter().enumerate() {
211 crate::deadline::check_current_every(i, 256, "edge construction (python)");
215 let self_defs = frag_defines.get(&f.id).cloned().unwrap_or_default();
216 let src_imports = frag_imports.get(&f.id).cloned().unwrap_or_default();
217
218 let calls = extract_calls(&f.content);
219 let type_refs = extract_type_refs(&f.content);
220 let refs: FxHashSet<String> = f
221 .identifiers
222 .iter()
223 .filter(|id| !self_defs.contains(*id))
224 .cloned()
225 .collect();
226
227 for (ref_set, base_weight) in [
228 (&calls, call_weight),
229 (&refs, symbol_ref_weight),
230 (&type_refs, type_ref_weight),
231 ] {
232 for name in ref_set {
233 if self_defs.contains(name) {
234 continue;
235 }
236 if name_def_files
243 .get(name)
244 .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
245 {
246 continue;
247 }
248 if let Some(dst_ids) = name_to_defs.get(name) {
249 for dst_id in dst_ids {
250 if dst_id == &f.id {
251 continue;
252 }
253 let dst_module =
254 frag_to_module.get(dst_id).map(|s| s.as_str()).unwrap_or("");
255 let confirmed =
256 !dst_module.is_empty() && src_imports.contains(dst_module);
257 let factor = if confirmed {
258 PYTHON_SEMANTIC.import_confirmed_boost
259 } else {
260 PYTHON_SEMANTIC.import_unconfirmed_penalty
261 };
262 let w = base_weight * factor;
263 let key_fwd = (f.id.clone(), dst_id.clone());
264 let existing = edges.get(&key_fwd).copied().unwrap_or(0.0);
265 if w > existing {
266 edges.insert(key_fwd, w);
267 }
268 let rev_w = w * PYTHON_SEMANTIC.reverse_factor;
269 let key_rev = (dst_id.clone(), f.id.clone());
270 let existing_rev = edges.get(&key_rev).copied().unwrap_or(0.0);
271 if rev_w > existing_rev {
272 edges.insert(key_rev, rev_w);
273 }
274 }
275 }
276 }
277 }
278
279 for imp in &src_imports {
280 if let Some(targets) = module_to_frags.get(imp) {
281 for tgt in targets {
282 if tgt == &f.id {
283 continue;
284 }
285 base::add_edge(
286 &mut edges,
287 &f.id,
288 tgt,
289 PYTHON_SEMANTIC.import_weight,
290 PYTHON_SEMANTIC.reverse_factor,
291 );
292 }
293 }
294 }
295 }
296
297 edges
298 }
299
300 fn discover_related_files(
301 &self,
302 changed: &[PathBuf],
303 candidates: &[PathBuf],
304 repo_root: Option<&Path>,
305 file_cache: Option<&FxHashMap<PathBuf, String>>,
306 ) -> Vec<PathBuf> {
307 let py_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_python_file(f)).collect();
308 if py_changed.is_empty() {
309 return vec![];
310 }
311
312 let mut file_to_module: FxHashMap<PathBuf, String> = FxHashMap::default();
313 let mut module_to_files: FxHashMap<String, Vec<PathBuf>> = FxHashMap::default();
314 let mut file_to_imports: FxHashMap<PathBuf, FxHashSet<String>> = FxHashMap::default();
315
316 for f in candidates {
317 if !is_python_file(f) {
318 continue;
319 }
320 let module = path_to_module(f, repo_root);
321 if !module.is_empty() {
322 file_to_module.insert(f.clone(), module.clone());
323 module_to_files
324 .entry(module.clone())
325 .or_default()
326 .push(f.clone());
327 let parts: Vec<&str> = module.split('.').collect();
328 for i in 1..parts.len() {
329 module_to_files
330 .entry(parts[..i].join("."))
331 .or_default()
332 .push(f.clone());
333 }
334 }
335 let content = base::read_file_cached(f, file_cache);
336 if let Some(c) = content {
337 file_to_imports.insert(f.clone(), extract_imports(&c, f, repo_root));
338 }
339 }
340
341 let changed_set: FxHashSet<PathBuf> = changed.iter().cloned().collect();
342 let mut discovered: FxHashSet<PathBuf> = FxHashSet::default();
343 let mut frontier: FxHashSet<PathBuf> = py_changed.iter().map(|f| (*f).clone()).collect();
344
345 for _ in 0..2 {
346 let mut next_frontier: FxHashSet<PathBuf> = FxHashSet::default();
347 for f in &frontier {
348 let f_imports = file_to_imports.get(f).cloned().unwrap_or_default();
349 for imp in &f_imports {
350 if let Some(targets) = module_to_files.get(imp) {
351 for target in targets {
352 if !changed_set.contains(target) && !discovered.contains(target) {
353 discovered.insert(target.clone());
354 next_frontier.insert(target.clone());
355 }
356 }
357 }
358 }
359 let f_module = file_to_module
360 .get(f)
361 .cloned()
362 .unwrap_or_else(|| path_to_module(f, repo_root));
363 if !f_module.is_empty() {
364 for (candidate, cand_imports) in &file_to_imports {
365 if !changed_set.contains(candidate)
366 && !discovered.contains(candidate)
367 && cand_imports.contains(&f_module)
368 {
369 discovered.insert(candidate.clone());
370 next_frontier.insert(candidate.clone());
371 }
372 }
373 }
374 }
375 if next_frontier.is_empty() {
376 break;
377 }
378 frontier = next_frontier;
379 }
380
381 let mut result: Vec<PathBuf> = discovered.into_iter().collect();
382 result.sort();
383 result
384 }
385}