Skip to main content

_diffctx/edges/semantic/
rust_lang.rs

1use std::path::{Path, PathBuf};
2
3/// Same ambiguity bar as `CFamilySemanticWeights::max_files_per_name`.
4const 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        // Re-export registration runs over the COMPLETE definition maps:
265        // inline in the loop above it saw only files iterated before the
266        // re-exporting one, so which `pub use` leaves resolved depended on
267        // file iteration order (#199). Registrations are collected before
268        // insertion so re-exporters do not shadow each other either.
269        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        // Same ambiguity bar as CFamilySemanticWeights::max_files_per_name: a
293        // name defined in more files than this is vocabulary, not a
294        // dependency. Uncapped, the type/fn channels alone emitted 35.4M
295        // edges on a polars-scale commit (#196).
296        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        // The re-exporting file iterates FIRST, the defining file after it —
552        // exactly the order that dropped the registration before #199.
553        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}