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
264 let mut pub_use_regs: Vec<(String, FragmentId)> = Vec::new();
270 for f in &rust_frags {
271 for pub_use_path in extract_pub_uses(&f.content) {
272 let leaf_lower = pub_use_path.split("::").last().unwrap_or("").to_lowercase();
273 if leaf_lower.is_empty() || name_to_frags.contains_key(&leaf_lower) {
274 continue;
275 }
276 let has_target = type_defs
277 .get(&leaf_lower)
278 .map_or(false, |v| v.iter().any(|fid| fid != &f.id))
279 || fn_defs
280 .get(&leaf_lower)
281 .map_or(false, |v| v.iter().any(|fid| fid != &f.id));
282 if has_target {
283 pub_use_regs.push((leaf_lower, f.id.clone()));
284 }
285 }
286 }
287 for (leaf_lower, fid) in pub_use_regs {
288 name_to_frags.entry(leaf_lower).or_default().push(fid);
289 }
290
291 let mut edges: EdgeDict = FxHashMap::default();
292 let name_capped = |name: &str| {
297 def_files
298 .get(name)
299 .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
300 };
301
302 for (impl_fid, pairs) in &trait_impls {
303 for (trait_name, _type_name) in pairs {
304 for trait_fid in type_defs.get(&trait_name.to_lowercase()).unwrap_or(&vec![]) {
305 if trait_fid != impl_fid {
306 add_edge(&mut edges, impl_fid, trait_fid, type_weight, reverse_factor);
307 }
308 }
309 }
310 }
311
312 for f in &rust_frags {
313 for pub_use_path in extract_pub_uses(&f.content) {
314 let leaf_lower = pub_use_path.split("::").last().unwrap_or("").to_lowercase();
315 for target_list in [type_defs.get(&leaf_lower), fn_defs.get(&leaf_lower)]
316 .iter()
317 .flatten()
318 {
319 for target_fid in *target_list {
320 if target_fid != &f.id {
321 add_edge(&mut edges, &f.id, target_fid, use_weight, reverse_factor);
322 }
323 }
324 }
325 }
326 }
327
328 for rf in &rust_frags {
329 let (type_refs, fn_calls, path_calls) = extract_references(&rf.content);
330
331 for use_path in extract_uses(&rf.content) {
332 for part in use_path.split("::") {
333 let part_lower = part.to_lowercase();
334 if mod_files
335 .get(&part_lower)
336 .is_none_or(|fs| fs.len() <= MAX_FILES_PER_NAME)
337 {
338 add_edges_from_ids(
339 &mut edges,
340 &rf.id,
341 mod_to_frags.get(&part_lower).unwrap_or(&vec![]),
342 use_weight,
343 reverse_factor,
344 );
345 }
346 if name_files
347 .get(&part_lower)
348 .is_none_or(|fs| fs.len() <= MAX_FILES_PER_NAME)
349 {
350 add_edges_from_ids(
351 &mut edges,
352 &rf.id,
353 name_to_frags.get(&part_lower).unwrap_or(&vec![]),
354 use_weight,
355 reverse_factor,
356 );
357 }
358 }
359 }
360
361 for mod_name in extract_mods(&rf.content) {
362 for fid in name_to_frags
363 .get(&mod_name.to_lowercase())
364 .unwrap_or(&vec![])
365 {
366 if fid != &rf.id {
367 add_edge(&mut edges, &rf.id, fid, mod_weight, reverse_factor);
368 }
369 }
370 }
371
372 for type_ref in &type_refs {
373 let lower = type_ref.to_lowercase();
374 if name_capped(&lower) {
375 continue;
376 }
377 for fid in type_defs.get(&lower).unwrap_or(&vec![]) {
378 if fid != &rf.id {
379 add_edge(&mut edges, &rf.id, fid, type_weight, reverse_factor);
380 }
381 }
382 }
383
384 for fn_call in &fn_calls {
385 let lower = fn_call.to_lowercase();
386 if name_capped(&lower) {
387 continue;
388 }
389 for fid in fn_defs.get(&lower).unwrap_or(&vec![]) {
390 if fid != &rf.id {
391 add_edge(&mut edges, &rf.id, fid, fn_weight, reverse_factor);
392 }
393 }
394 }
395
396 for (mod_name, _symbol) in &path_calls {
397 for fid in mod_to_frags
398 .get(&mod_name.to_lowercase())
399 .unwrap_or(&vec![])
400 {
401 if fid != &rf.id {
402 add_edge(&mut edges, &rf.id, fid, use_weight, reverse_factor);
403 }
404 }
405 }
406
407 let stem = Path::new(rf.path())
408 .file_stem()
409 .map(|s| s.to_string_lossy().to_lowercase())
410 .unwrap_or_default();
411 if stem == "lib" || stem == "mod" {
412 let parent_dir = Path::new(rf.path()).parent();
413 for other in &rust_frags {
414 if let Some(pd) = parent_dir {
415 if Path::new(other.path()).parent() == Some(pd) && other.id != rf.id {
416 add_edge(
417 &mut edges,
418 &rf.id,
419 &other.id,
420 same_crate_weight,
421 reverse_factor,
422 );
423 }
424 }
425 }
426 }
427 }
428
429 edges
430 }
431
432 fn discover_related_files(
433 &self,
434 changed: &[PathBuf],
435 candidates: &[PathBuf],
436 _repo_root: Option<&Path>,
437 file_cache: Option<&FxHashMap<PathBuf, String>>,
438 ) -> Vec<PathBuf> {
439 let rust_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_rust_file(f)).collect();
440 if rust_changed.is_empty() {
441 return vec![];
442 }
443
444 let rust_candidates: Vec<PathBuf> = candidates
445 .iter()
446 .filter(|c| is_rust_file(c))
447 .cloned()
448 .collect();
449
450 let mut mod_name_to_files: FxHashMap<String, Vec<PathBuf>> = FxHashMap::default();
451 let mut file_uses: FxHashMap<PathBuf, FxHashSet<String>> = FxHashMap::default();
452 let mut file_mods: FxHashMap<PathBuf, FxHashSet<String>> = FxHashMap::default();
453
454 for candidate in &rust_candidates {
455 mod_name_to_files
456 .entry(stem_to_mod_name(candidate))
457 .or_default()
458 .push(candidate.clone());
459 if let Some(content) = base::read_file_cached(candidate, file_cache) {
460 file_uses.insert(candidate.clone(), extract_uses(&content));
461 file_mods.insert(candidate.clone(), extract_mods(&content));
462 }
463 }
464
465 let changed_set: FxHashSet<PathBuf> = changed.iter().cloned().collect();
466 let mut discovered: FxHashSet<PathBuf> = FxHashSet::default();
467 let mut frontier: FxHashSet<PathBuf> = rust_changed.iter().map(|f| (*f).clone()).collect();
468
469 for _ in 0..SEMANTIC_DISCOVERY.max_depth {
470 let skip: FxHashSet<PathBuf> = changed_set.union(&discovered).cloned().collect();
471 let frontier_mod_names: FxHashSet<String> =
472 frontier.iter().map(|f| stem_to_mod_name(f)).collect();
473
474 let mut forward_targets: FxHashSet<String> = FxHashSet::default();
475 for f in &frontier {
476 if let Some(uses) = file_uses.get(f) {
477 for use_path in uses {
478 for part in use_path.split("::") {
479 forward_targets.insert(part.to_lowercase());
480 }
481 }
482 }
483 if let Some(mods) = file_mods.get(f) {
484 for m in mods {
485 forward_targets.insert(m.to_lowercase());
486 }
487 }
488 }
489
490 let mut found: FxHashSet<PathBuf> = FxHashSet::default();
491
492 for target_name in &forward_targets {
493 for candidate in mod_name_to_files.get(target_name).unwrap_or(&vec![]) {
494 if !skip.contains(candidate) && !found.contains(candidate) {
495 found.insert(candidate.clone());
496 }
497 }
498 }
499
500 for candidate in &rust_candidates {
501 if skip.contains(candidate) || found.contains(candidate) {
502 continue;
503 }
504 let cand_mods = file_mods.get(candidate).cloned().unwrap_or_default();
505 if !cand_mods.is_disjoint(&frontier_mod_names) {
506 found.insert(candidate.clone());
507 continue;
508 }
509 if let Some(uses) = file_uses.get(candidate) {
510 let use_parts: FxHashSet<String> = uses
511 .iter()
512 .flat_map(|p| p.split("::").map(|s| s.to_lowercase()))
513 .collect();
514 if !use_parts.is_disjoint(&frontier_mod_names) {
515 found.insert(candidate.clone());
516 }
517 }
518 }
519
520 if found.is_empty() {
521 break;
522 }
523 discovered.extend(found.iter().cloned());
524 frontier = found;
525 }
526
527 let mut result: Vec<PathBuf> = discovered.into_iter().collect();
528 result.sort();
529 result
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::types::FragmentKind;
537
538 fn frag(path: &str, content: &str) -> Fragment {
539 Fragment {
540 id: FragmentId::new(std::sync::Arc::from(path), 1, 20),
541 kind: FragmentKind::Function,
542 content: std::sync::Arc::from(content),
543 identifiers: FxHashSet::default(),
544 token_count: 20,
545 symbol_name: None,
546 }
547 }
548
549 #[test]
550 fn pub_use_registration_does_not_depend_on_file_iteration_order() {
551 let facade = frag("src/facade.rs", "pub use detail::WidgetFactory;\n");
554 let detail = frag(
555 "src/z_detail.rs",
556 "pub struct WidgetFactory {\n size: u32,\n}\n",
557 );
558 let consumer = frag(
559 "src/consumer.rs",
560 "use crate::api::WidgetFactory;\n\nfn run() {}\n",
561 );
562 let frags = vec![facade.clone(), detail, consumer.clone()];
563 let edges = RustEdgeBuilder.build(&frags, None);
564 assert!(
565 edges.contains_key(&(consumer.id.clone(), facade.id.clone())),
566 "a use of the re-exported leaf must resolve to the re-exporting file"
567 );
568 }
569}