1use std::path::{Path, PathBuf};
2
3const MAX_FILES_PER_NAME: usize = 8;
5
6use once_cell::sync::Lazy;
7use regex::Regex;
8use rustc_hash::{FxHashMap, FxHashSet};
9
10use crate::config::edge_weights::SEMANTIC_DISCOVERY;
11use crate::config::extensions::RUST_EXTENSIONS;
12use crate::config::weights::EDGE_WEIGHTS;
13use crate::types::{Fragment, FragmentId};
14
15use super::super::EdgeDict;
16use super::super::base::{self, EdgeBuilder, add_edge, add_edges_from_ids};
17
18fn is_rust_file(path: &Path) -> bool {
19 let ext = base::file_ext(path);
20 RUST_EXTENSIONS.contains(ext.as_str())
21}
22
23static USE_RE: Lazy<Regex> =
24 Lazy::new(|| Regex::new(r"(?m)^\s*use\s+([\w:]+(?:::\{[^}]+\})?)").unwrap());
25static MOD_RE: Lazy<Regex> =
26 Lazy::new(|| Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)\s*[;{]").unwrap());
27static TYPE_DEF_RE: Lazy<Regex> = Lazy::new(|| {
28 Regex::new(r"(?m)^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|type|union)\s+([A-Z]\w*)")
29 .unwrap()
30});
31static FN_DEF_RE: Lazy<Regex> = Lazy::new(|| {
32 Regex::new(r"(?m)^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([a-z_]\w*)").unwrap()
33});
34static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Z]\w*)\b").unwrap());
35static FN_CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([a-z_]\w+)\s*[(<]").unwrap());
36static PATH_CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b(\w+)::(\w+)").unwrap());
37static IMPL_RE: Lazy<Regex> =
38 Lazy::new(|| Regex::new(r"(?m)^\s*impl(?:<[^>]*>)?\s+(\w+)\s+for\s+(\w+)").unwrap());
39static PUB_USE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^\s*pub\s+use\s+([\w:]+)").unwrap());
40
41static RUST_KEYWORDS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
42 [
43 "if",
44 "else",
45 "for",
46 "while",
47 "loop",
48 "match",
49 "return",
50 "break",
51 "continue",
52 "let",
53 "mut",
54 "ref",
55 "fn",
56 "pub",
57 "mod",
58 "use",
59 "struct",
60 "enum",
61 "trait",
62 "impl",
63 "type",
64 "where",
65 "as",
66 "in",
67 "self",
68 "super",
69 "crate",
70 "extern",
71 "async",
72 "await",
73 "move",
74 "unsafe",
75 "const",
76 "static",
77 "dyn",
78 "box",
79 "true",
80 "false",
81 "Some",
82 "None",
83 "Ok",
84 "Err",
85 "vec",
86 "println",
87 "eprintln",
88 "format",
89 "write",
90 "writeln",
91 "panic",
92 "assert",
93 "assert_eq",
94 "assert_ne",
95 "debug_assert",
96 "todo",
97 "unimplemented",
98 "unreachable",
99 "cfg",
100 "derive",
101 "allow",
102 "deny",
103 "warn",
104 ]
105 .iter()
106 .copied()
107 .collect()
108});
109
110fn extract_uses(content: &str) -> FxHashSet<String> {
111 USE_RE
112 .captures_iter(content)
113 .map(|c| c[1].to_string())
114 .collect()
115}
116
117fn extract_mods(content: &str) -> FxHashSet<String> {
118 MOD_RE
119 .captures_iter(content)
120 .map(|c| c[1].to_string())
121 .collect()
122}
123
124fn extract_definitions(content: &str) -> (FxHashSet<String>, FxHashSet<String>) {
125 let funcs: FxHashSet<String> = FN_DEF_RE
126 .captures_iter(content)
127 .map(|c| c[1].to_string())
128 .collect();
129 let types: FxHashSet<String> = TYPE_DEF_RE
130 .captures_iter(content)
131 .map(|c| c[1].to_string())
132 .collect();
133 (funcs, types)
134}
135
136fn extract_trait_impls(content: &str) -> Vec<(String, String)> {
137 IMPL_RE
138 .captures_iter(content)
139 .map(|c| (c[1].to_string(), c[2].to_string()))
140 .collect()
141}
142
143fn extract_pub_uses(content: &str) -> Vec<String> {
144 PUB_USE_RE
145 .captures_iter(content)
146 .map(|c| c[1].to_string())
147 .collect()
148}
149
150fn extract_references(
151 content: &str,
152) -> (
153 FxHashSet<String>,
154 FxHashSet<String>,
155 FxHashSet<(String, String)>,
156) {
157 let type_refs: FxHashSet<String> = TYPE_REF_RE
158 .captures_iter(content)
159 .map(|c| c[1].to_string())
160 .filter(|n| !RUST_KEYWORDS.contains(n.as_str()))
161 .collect();
162 let fn_calls: FxHashSet<String> = FN_CALL_RE
163 .captures_iter(content)
164 .map(|c| c[1].to_string())
165 .filter(|n| !RUST_KEYWORDS.contains(n.as_str()))
166 .collect();
167 let path_calls: FxHashSet<(String, String)> = PATH_CALL_RE
168 .captures_iter(content)
169 .map(|c| (c[1].to_string(), c[2].to_string()))
170 .collect();
171 (type_refs, fn_calls, path_calls)
172}
173
174fn stem_to_mod_name(path: &Path) -> String {
175 let stem = path
176 .file_stem()
177 .map(|s| s.to_string_lossy().to_lowercase())
178 .unwrap_or_default();
179 if stem == "mod" || stem == "lib" {
180 path.parent()
181 .and_then(|p| p.file_name())
182 .map(|n| n.to_string_lossy().to_lowercase())
183 .unwrap_or(stem)
184 } else {
185 stem
186 }
187}
188
189pub struct RustEdgeBuilder;
190
191impl EdgeBuilder for RustEdgeBuilder {
192 fn build(&self, fragments: &[Fragment], _repo_root: Option<&Path>) -> EdgeDict {
193 let rust_frags: Vec<&Fragment> = fragments
194 .iter()
195 .filter(|f| is_rust_file(Path::new(f.path())))
196 .collect();
197 if rust_frags.is_empty() {
198 return FxHashMap::default();
199 }
200
201 let mod_weight = EDGE_WEIGHTS["rust_mod"].forward;
202 let use_weight = EDGE_WEIGHTS["rust_use"].forward;
203 let type_weight = EDGE_WEIGHTS["rust_type"].forward;
204 let fn_weight = EDGE_WEIGHTS["rust_fn"].forward;
205 let same_crate_weight = EDGE_WEIGHTS["rust_same_crate"].forward;
206 let reverse_factor = EDGE_WEIGHTS["rust_mod"].reverse_factor;
207
208 let mut name_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
209 let mut mod_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
210 let mut type_defs: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
211 let mut fn_defs: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
212 let mut def_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
213 let mut name_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
214 let mut mod_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
215 let mut trait_impls: FxHashMap<FragmentId, Vec<(String, String)>> = FxHashMap::default();
216
217 for f in &rust_frags {
218 let path = Path::new(f.path());
219 let stem = path
220 .file_stem()
221 .map(|s| s.to_string_lossy().to_lowercase())
222 .unwrap_or_default();
223 name_files.entry(stem.clone()).or_default().insert(f.path());
224 name_to_frags
225 .entry(stem.clone())
226 .or_default()
227 .push(f.id.clone());
228
229 if stem == "mod" || stem == "lib" {
230 if let Some(parent_name) = path.parent().and_then(|p| p.file_name()) {
231 let key = parent_name.to_string_lossy().to_lowercase();
232 mod_files.entry(key.clone()).or_default().insert(f.path());
233 mod_to_frags.entry(key).or_default().push(f.id.clone());
234 }
235 } else {
236 mod_files.entry(stem.clone()).or_default().insert(f.path());
237 mod_to_frags.entry(stem).or_default().push(f.id.clone());
238 }
239
240 let (funcs, types) = extract_definitions(&f.content);
241 for t in types {
242 let lower = t.to_lowercase();
243 def_files.entry(lower.clone()).or_default().insert(f.path());
244 type_defs.entry(lower).or_default().push(f.id.clone());
245 }
246 for func in funcs {
247 let lower = func.to_lowercase();
248 def_files.entry(lower.clone()).or_default().insert(f.path());
249 fn_defs.entry(lower).or_default().push(f.id.clone());
250 }
251
252 for mod_name in extract_mods(&f.content) {
253 let key = mod_name.to_lowercase();
254 mod_files.entry(key.clone()).or_default().insert(f.path());
255 mod_to_frags.entry(key).or_default().push(f.id.clone());
256 }
257
258 let impls = extract_trait_impls(&f.content);
259 if !impls.is_empty() {
260 trait_impls.insert(f.id.clone(), impls);
261 }
262
263 for pub_use_path in extract_pub_uses(&f.content) {
264 let leaf_lower = pub_use_path.split("::").last().unwrap_or("").to_lowercase();
265 if !leaf_lower.is_empty() && !name_to_frags.contains_key(&leaf_lower) {
266 let has_target = type_defs
267 .get(&leaf_lower)
268 .map_or(false, |v| v.iter().any(|fid| fid != &f.id))
269 || fn_defs
270 .get(&leaf_lower)
271 .map_or(false, |v| v.iter().any(|fid| fid != &f.id));
272 if has_target {
273 name_to_frags
274 .entry(leaf_lower)
275 .or_default()
276 .push(f.id.clone());
277 }
278 }
279 }
280 }
281
282 let mut edges: EdgeDict = FxHashMap::default();
283 let name_capped = |name: &str| {
288 def_files
289 .get(name)
290 .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
291 };
292
293 for (impl_fid, pairs) in &trait_impls {
294 for (trait_name, _type_name) in pairs {
295 for trait_fid in type_defs.get(&trait_name.to_lowercase()).unwrap_or(&vec![]) {
296 if trait_fid != impl_fid {
297 add_edge(&mut edges, impl_fid, trait_fid, type_weight, reverse_factor);
298 }
299 }
300 }
301 }
302
303 for f in &rust_frags {
304 for pub_use_path in extract_pub_uses(&f.content) {
305 let leaf_lower = pub_use_path.split("::").last().unwrap_or("").to_lowercase();
306 for target_list in [type_defs.get(&leaf_lower), fn_defs.get(&leaf_lower)]
307 .iter()
308 .flatten()
309 {
310 for target_fid in *target_list {
311 if target_fid != &f.id {
312 add_edge(&mut edges, &f.id, target_fid, use_weight, reverse_factor);
313 }
314 }
315 }
316 }
317 }
318
319 for rf in &rust_frags {
320 let (type_refs, fn_calls, path_calls) = extract_references(&rf.content);
321
322 for use_path in extract_uses(&rf.content) {
323 for part in use_path.split("::") {
324 let part_lower = part.to_lowercase();
325 if mod_files
326 .get(&part_lower)
327 .is_none_or(|fs| fs.len() <= MAX_FILES_PER_NAME)
328 {
329 add_edges_from_ids(
330 &mut edges,
331 &rf.id,
332 mod_to_frags.get(&part_lower).unwrap_or(&vec![]),
333 use_weight,
334 reverse_factor,
335 );
336 }
337 if name_files
338 .get(&part_lower)
339 .is_none_or(|fs| fs.len() <= MAX_FILES_PER_NAME)
340 {
341 add_edges_from_ids(
342 &mut edges,
343 &rf.id,
344 name_to_frags.get(&part_lower).unwrap_or(&vec![]),
345 use_weight,
346 reverse_factor,
347 );
348 }
349 }
350 }
351
352 for mod_name in extract_mods(&rf.content) {
353 for fid in name_to_frags
354 .get(&mod_name.to_lowercase())
355 .unwrap_or(&vec![])
356 {
357 if fid != &rf.id {
358 add_edge(&mut edges, &rf.id, fid, mod_weight, reverse_factor);
359 }
360 }
361 }
362
363 for type_ref in &type_refs {
364 let lower = type_ref.to_lowercase();
365 if name_capped(&lower) {
366 continue;
367 }
368 for fid in type_defs.get(&lower).unwrap_or(&vec![]) {
369 if fid != &rf.id {
370 add_edge(&mut edges, &rf.id, fid, type_weight, reverse_factor);
371 }
372 }
373 }
374
375 for fn_call in &fn_calls {
376 let lower = fn_call.to_lowercase();
377 if name_capped(&lower) {
378 continue;
379 }
380 for fid in fn_defs.get(&lower).unwrap_or(&vec![]) {
381 if fid != &rf.id {
382 add_edge(&mut edges, &rf.id, fid, fn_weight, reverse_factor);
383 }
384 }
385 }
386
387 for (mod_name, _symbol) in &path_calls {
388 for fid in mod_to_frags
389 .get(&mod_name.to_lowercase())
390 .unwrap_or(&vec![])
391 {
392 if fid != &rf.id {
393 add_edge(&mut edges, &rf.id, fid, use_weight, reverse_factor);
394 }
395 }
396 }
397
398 let stem = Path::new(rf.path())
399 .file_stem()
400 .map(|s| s.to_string_lossy().to_lowercase())
401 .unwrap_or_default();
402 if stem == "lib" || stem == "mod" {
403 let parent_dir = Path::new(rf.path()).parent();
404 for other in &rust_frags {
405 if let Some(pd) = parent_dir {
406 if Path::new(other.path()).parent() == Some(pd) && other.id != rf.id {
407 add_edge(
408 &mut edges,
409 &rf.id,
410 &other.id,
411 same_crate_weight,
412 reverse_factor,
413 );
414 }
415 }
416 }
417 }
418 }
419
420 edges
421 }
422
423 fn discover_related_files(
424 &self,
425 changed: &[PathBuf],
426 candidates: &[PathBuf],
427 _repo_root: Option<&Path>,
428 file_cache: Option<&FxHashMap<PathBuf, String>>,
429 ) -> Vec<PathBuf> {
430 let rust_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_rust_file(f)).collect();
431 if rust_changed.is_empty() {
432 return vec![];
433 }
434
435 let rust_candidates: Vec<PathBuf> = candidates
436 .iter()
437 .filter(|c| is_rust_file(c))
438 .cloned()
439 .collect();
440
441 let mut mod_name_to_files: FxHashMap<String, Vec<PathBuf>> = FxHashMap::default();
442 let mut file_uses: FxHashMap<PathBuf, FxHashSet<String>> = FxHashMap::default();
443 let mut file_mods: FxHashMap<PathBuf, FxHashSet<String>> = FxHashMap::default();
444
445 for candidate in &rust_candidates {
446 mod_name_to_files
447 .entry(stem_to_mod_name(candidate))
448 .or_default()
449 .push(candidate.clone());
450 if let Some(content) = base::read_file_cached(candidate, file_cache) {
451 file_uses.insert(candidate.clone(), extract_uses(&content));
452 file_mods.insert(candidate.clone(), extract_mods(&content));
453 }
454 }
455
456 let changed_set: FxHashSet<PathBuf> = changed.iter().cloned().collect();
457 let mut discovered: FxHashSet<PathBuf> = FxHashSet::default();
458 let mut frontier: FxHashSet<PathBuf> = rust_changed.iter().map(|f| (*f).clone()).collect();
459
460 for _ in 0..SEMANTIC_DISCOVERY.max_depth {
461 let skip: FxHashSet<PathBuf> = changed_set.union(&discovered).cloned().collect();
462 let frontier_mod_names: FxHashSet<String> =
463 frontier.iter().map(|f| stem_to_mod_name(f)).collect();
464
465 let mut forward_targets: FxHashSet<String> = FxHashSet::default();
466 for f in &frontier {
467 if let Some(uses) = file_uses.get(f) {
468 for use_path in uses {
469 for part in use_path.split("::") {
470 forward_targets.insert(part.to_lowercase());
471 }
472 }
473 }
474 if let Some(mods) = file_mods.get(f) {
475 for m in mods {
476 forward_targets.insert(m.to_lowercase());
477 }
478 }
479 }
480
481 let mut found: FxHashSet<PathBuf> = FxHashSet::default();
482
483 for target_name in &forward_targets {
484 for candidate in mod_name_to_files.get(target_name).unwrap_or(&vec![]) {
485 if !skip.contains(candidate) && !found.contains(candidate) {
486 found.insert(candidate.clone());
487 }
488 }
489 }
490
491 for candidate in &rust_candidates {
492 if skip.contains(candidate) || found.contains(candidate) {
493 continue;
494 }
495 let cand_mods = file_mods.get(candidate).cloned().unwrap_or_default();
496 if !cand_mods.is_disjoint(&frontier_mod_names) {
497 found.insert(candidate.clone());
498 continue;
499 }
500 if let Some(uses) = file_uses.get(candidate) {
501 let use_parts: FxHashSet<String> = uses
502 .iter()
503 .flat_map(|p| p.split("::").map(|s| s.to_lowercase()))
504 .collect();
505 if !use_parts.is_disjoint(&frontier_mod_names) {
506 found.insert(candidate.clone());
507 }
508 }
509 }
510
511 if found.is_empty() {
512 break;
513 }
514 discovered.extend(found.iter().cloned());
515 frontier = found;
516 }
517
518 let mut result: Vec<PathBuf> = discovered.into_iter().collect();
519 result.sort();
520 result
521 }
522}