Skip to main content

_diffctx/edges/semantic/
dotnet.rs

1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::extensions::DOTNET_EXTENSIONS;
8use crate::config::weights::EDGE_WEIGHTS;
9use crate::types::{Fragment, FragmentId, FragmentKind};
10
11use super::super::EdgeDict;
12use super::super::base::{
13    self, EdgeBuilder, FragmentIndex, add_edge, discover_files_by_refs, link_by_name,
14};
15
16/// Same ambiguity bar as `CFamilySemanticWeights::max_files_per_name`: a name
17/// defined in more files than this is vocabulary, not a dependency, and is
18/// skipped outright rather than truncated.
19const MAX_FILES_PER_NAME: usize = 8;
20
21static EXTENDED_DOTNET_EXTENSIONS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
22    DOTNET_EXTENSIONS
23        .iter()
24        .copied()
25        .chain([".vb", ".csproj", ".fsproj", ".sln"])
26        .collect()
27});
28
29fn is_dotnet_file(path: &Path) -> bool {
30    let ext = base::file_ext(path);
31    EXTENDED_DOTNET_EXTENSIONS.contains(ext.as_str())
32}
33
34fn is_cs_file(path: &Path) -> bool {
35    base::file_ext(path) == ".cs"
36}
37
38fn is_fs_file(path: &Path) -> bool {
39    let ext = base::file_ext(path);
40    ext == ".fs" || ext == ".fsi" || ext == ".fsx"
41}
42
43static CS_USING_RE: Lazy<Regex> = Lazy::new(|| {
44    Regex::new(r"(?m)^\s*(?:global\s+)?using\s+(?:static\s+)?(?:\w+\s*=\s*)?([A-Z][\w.]+)").unwrap()
45});
46static FS_OPEN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^\s*open\s+([A-Z][\w.]+)").unwrap());
47static NAMESPACE_RE: Lazy<Regex> =
48    Lazy::new(|| Regex::new(r"(?m)^\s*namespace\s+([A-Z][\w.]+)").unwrap());
49static TYPE_DEF_RE: Lazy<Regex> = Lazy::new(|| {
50    Regex::new(
51        r"(?m)^\s*(?:public|internal|private|protected)?\s*(?:static|abstract|sealed|partial)?\s*(?:class|struct|interface|enum|record)\s+(\w+)",
52    )
53    .unwrap()
54});
55static INHERITANCE_RE: Lazy<Regex> = Lazy::new(|| {
56    Regex::new(r"(?:class|struct|interface|record)\s+\w+\s*(?:<[^>]*>)?\s*:\s*(.+)").unwrap()
57});
58static ATTRIBUTE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\[(\w+)(?:\(|])").unwrap());
59static PARTIAL_RE: Lazy<Regex> = Lazy::new(|| {
60    Regex::new(r"(?m)^\s*(?:public|internal|private|protected)?\s*partial\s+(?:class|struct|interface|record)\s+(\w+)").unwrap()
61});
62static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Z]\w+)\b").unwrap());
63static MEMBER_USE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.\s*([a-zA-Z_]\w{2,})").unwrap());
64
65static DOTNET_KEYWORDS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
66    [
67        "String",
68        "Int32",
69        "Boolean",
70        "Object",
71        "Void",
72        "Task",
73        "Action",
74        "Func",
75        "List",
76        "Dictionary",
77        "IEnumerable",
78        "IList",
79        "ICollection",
80        "Exception",
81        "Console",
82        "Math",
83        "Convert",
84        "Type",
85        "Attribute",
86        "Nullable",
87        "if",
88        "else",
89        "for",
90        "while",
91        "do",
92        "switch",
93        "case",
94        "break",
95        "continue",
96        "return",
97        "new",
98        "this",
99        "base",
100        "null",
101        "true",
102        "false",
103        "var",
104        "dynamic",
105        "async",
106        "await",
107        "try",
108        "catch",
109        "finally",
110        "throw",
111        "using",
112        "namespace",
113        "class",
114        "struct",
115        "interface",
116        "enum",
117        "record",
118        "delegate",
119        "event",
120        "public",
121        "private",
122        "protected",
123        "internal",
124        "static",
125        "abstract",
126        "sealed",
127        "virtual",
128        "override",
129        "partial",
130        "readonly",
131        "const",
132        "ref",
133        "out",
134        "in",
135    ]
136    .iter()
137    .copied()
138    .collect()
139});
140
141fn extract_usings(content: &str, path: &Path) -> FxHashSet<String> {
142    let mut refs = FxHashSet::default();
143    if is_cs_file(path) {
144        for cap in CS_USING_RE.captures_iter(content) {
145            refs.insert(cap[1].to_string());
146        }
147    }
148    if is_fs_file(path) {
149        for cap in FS_OPEN_RE.captures_iter(content) {
150            refs.insert(cap[1].to_string());
151        }
152    }
153    refs
154}
155
156fn extract_namespaces(content: &str) -> FxHashSet<String> {
157    NAMESPACE_RE
158        .captures_iter(content)
159        .map(|c| c[1].to_string())
160        .collect()
161}
162
163fn extract_defines(content: &str) -> FxHashSet<String> {
164    let mut defs = FxHashSet::default();
165    for cap in TYPE_DEF_RE.captures_iter(content) {
166        defs.insert(cap[1].to_string());
167    }
168    defs
169}
170
171fn extract_partials(content: &str) -> FxHashSet<String> {
172    PARTIAL_RE
173        .captures_iter(content)
174        .map(|c| c[1].to_string())
175        .collect()
176}
177
178fn extract_base_types(content: &str) -> FxHashSet<String> {
179    let mut bases = FxHashSet::default();
180    for cap in INHERITANCE_RE.captures_iter(content) {
181        for part in cap[1].split(',') {
182            let trimmed = part.trim().split('<').next().unwrap_or("").trim();
183            if !trimmed.is_empty() && trimmed.chars().next().map_or(false, |c| c.is_uppercase()) {
184                bases.insert(trimmed.to_string());
185            }
186        }
187    }
188    bases
189}
190
191fn extract_attributes(content: &str) -> FxHashSet<String> {
192    ATTRIBUTE_RE
193        .captures_iter(content)
194        .map(|c| c[1].to_string())
195        .filter(|n| !DOTNET_KEYWORDS.contains(n.as_str()))
196        .collect()
197}
198
199fn extract_type_refs(content: &str) -> FxHashSet<String> {
200    TYPE_REF_RE
201        .captures_iter(content)
202        .map(|c| c[1].to_string())
203        .filter(|n| !DOTNET_KEYWORDS.contains(n.as_str()))
204        .collect()
205}
206
207fn extract_member_uses(content: &str) -> FxHashSet<String> {
208    MEMBER_USE_RE
209        .captures_iter(content)
210        .map(|c| c[1].to_lowercase())
211        .collect()
212}
213
214fn is_member_def(f: &Fragment) -> bool {
215    matches!(f.kind, FragmentKind::Function | FragmentKind::Property)
216}
217
218struct FileRelations<'a> {
219    file_ns: FxHashMap<&'a str, FxHashSet<String>>,
220    file_usings: FxHashMap<&'a str, FxHashSet<String>>,
221    named_files: FxHashMap<&'a str, FxHashSet<&'a str>>,
222    inh_pairs: FxHashSet<(&'a str, &'a str)>,
223}
224
225impl<'a> FileRelations<'a> {
226    /// A member-name match alone is tags-grade evidence; it only becomes an
227    /// edge when the using file already relates to the defining file through
228    /// something it wrote down: a `using` naming the definer's namespace
229    /// (extension methods never name their static class), a named type, or
230    /// inheritance. Same-namespace proximity is deliberately not enough.
231    fn confirmed(&self, user: &str, definer: &str) -> bool {
232        if self
233            .named_files
234            .get(user)
235            .is_some_and(|s| s.contains(definer))
236            || self.inh_pairs.contains(&(user, definer))
237        {
238            return true;
239        }
240        match (self.file_usings.get(user), self.file_ns.get(definer)) {
241            (Some(usings), Some(nss)) => nss.iter().any(|ns| usings.contains(ns)),
242            _ => false,
243        }
244    }
245}
246
247fn link_defs<'a>(
248    edges: &mut EdgeDict,
249    rel: &mut FileRelations<'a>,
250    src: &'a Fragment,
251    name: &str,
252    weight: f64,
253    reverse_factor: f64,
254    name_to_defs: &'a FxHashMap<String, Vec<FragmentId>>,
255    name_def_files: &FxHashMap<String, FxHashSet<&'a str>>,
256) {
257    if name_def_files
258        .get(name)
259        .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
260    {
261        return;
262    }
263    if let Some(dst_ids) = name_to_defs.get(name) {
264        for dst_id in dst_ids {
265            if dst_id != &src.id {
266                add_edge(edges, &src.id, dst_id, weight, reverse_factor);
267                rel.named_files
268                    .entry(src.path())
269                    .or_default()
270                    .insert(dst_id.path.as_ref());
271            }
272        }
273    }
274}
275
276pub struct DotNetEdgeBuilder;
277
278impl EdgeBuilder for DotNetEdgeBuilder {
279    fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict {
280        let dn_frags: Vec<&Fragment> = fragments
281            .iter()
282            .filter(|f| is_dotnet_file(Path::new(f.path())))
283            .collect();
284        if dn_frags.is_empty() {
285            return FxHashMap::default();
286        }
287
288        let using_weight = EDGE_WEIGHTS["dotnet_using"].forward;
289        let inheritance_weight = EDGE_WEIGHTS["dotnet_inheritance"].forward;
290        let type_weight = EDGE_WEIGHTS["dotnet_type"].forward;
291        let member_weight = EDGE_WEIGHTS["dotnet_member"].forward;
292        let same_ns_weight = EDGE_WEIGHTS["dotnet_same_namespace"].forward;
293        let attribute_weight = EDGE_WEIGHTS["dotnet_attribute"].forward;
294        let partial_weight = EDGE_WEIGHTS["dotnet_partial"].forward;
295        let reverse_factor = EDGE_WEIGHTS["dotnet_using"].reverse_factor;
296
297        let idx = FragmentIndex::new(fragments, repo_root);
298
299        let mut name_to_defs: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
300        let mut name_def_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
301        let mut frag_defines: FxHashMap<FragmentId, FxHashSet<String>> = FxHashMap::default();
302        let mut ns_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
303        let mut frag_namespaces: FxHashMap<FragmentId, FxHashSet<String>> = FxHashMap::default();
304        let mut partial_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
305        let mut member_defs: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
306        let mut member_def_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
307        let mut file_ns: FxHashMap<&str, FxHashSet<String>> = FxHashMap::default();
308        let mut file_usings: FxHashMap<&str, FxHashSet<String>> = FxHashMap::default();
309
310        for f in &dn_frags {
311            let defs = extract_defines(&f.content);
312            for name in &defs {
313                name_to_defs
314                    .entry(name.clone())
315                    .or_default()
316                    .push(f.id.clone());
317                name_def_files
318                    .entry(name.clone())
319                    .or_default()
320                    .insert(f.path());
321            }
322            frag_defines.insert(f.id.clone(), defs);
323
324            let namespaces = extract_namespaces(&f.content);
325            for ns in &namespaces {
326                ns_to_frags
327                    .entry(ns.clone())
328                    .or_default()
329                    .push(f.id.clone());
330                file_ns.entry(f.path()).or_default().insert(ns.clone());
331            }
332            frag_namespaces.insert(f.id.clone(), namespaces);
333
334            let usings = extract_usings(&f.content, Path::new(f.path()));
335            if !usings.is_empty() {
336                file_usings
337                    .entry(f.path())
338                    .or_default()
339                    .extend(usings.iter().cloned());
340            }
341
342            let partials = extract_partials(&f.content);
343            for p in &partials {
344                partial_to_frags
345                    .entry(p.clone())
346                    .or_default()
347                    .push(f.id.clone());
348            }
349
350            if is_member_def(f) {
351                if let Some(name) = f.symbol_name.as_deref() {
352                    if name.len() >= 3 {
353                        let lower = name.to_lowercase();
354                        member_defs
355                            .entry(lower.clone())
356                            .or_default()
357                            .push(f.id.clone());
358                        member_def_files
359                            .entry(lower.clone())
360                            .or_default()
361                            .insert(f.path());
362                    }
363                }
364            }
365        }
366
367        let mut edges: EdgeDict = FxHashMap::default();
368        let mut rel = FileRelations {
369            file_ns,
370            file_usings,
371            named_files: FxHashMap::default(),
372            inh_pairs: FxHashSet::default(),
373        };
374
375        for f in &dn_frags {
376            let self_defs = frag_defines.get(&f.id).cloned().unwrap_or_default();
377            let self_ns = frag_namespaces.get(&f.id).cloned().unwrap_or_default();
378
379            let usings = extract_usings(&f.content, Path::new(f.path()));
380            for u in &usings {
381                if let Some(targets) = ns_to_frags.get(u) {
382                    for tgt in targets {
383                        if tgt != &f.id {
384                            add_edge(&mut edges, &f.id, tgt, using_weight, reverse_factor);
385                        }
386                    }
387                }
388                link_by_name(&f.id, u, &idx, &mut edges, using_weight, reverse_factor);
389            }
390
391            let base_types = extract_base_types(&f.content);
392            for bt in &base_types {
393                if name_def_files
394                    .get(bt)
395                    .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
396                {
397                    continue;
398                }
399                if let Some(dst_ids) = name_to_defs.get(bt) {
400                    for dst_id in dst_ids {
401                        if dst_id != &f.id {
402                            add_edge(
403                                &mut edges,
404                                &f.id,
405                                dst_id,
406                                inheritance_weight,
407                                reverse_factor,
408                            );
409                            let a = f.path();
410                            let b: &str = dst_id.path.as_ref();
411                            if a != b {
412                                rel.inh_pairs.insert((a, b));
413                                rel.inh_pairs.insert((b, a));
414                            }
415                        }
416                    }
417                }
418            }
419
420            let type_refs = extract_type_refs(&f.content);
421            for name in &type_refs {
422                if self_defs.contains(name) {
423                    continue;
424                }
425                link_defs(
426                    &mut edges,
427                    &mut rel,
428                    f,
429                    name,
430                    type_weight,
431                    reverse_factor,
432                    &name_to_defs,
433                    &name_def_files,
434                );
435            }
436
437            let attrs = extract_attributes(&f.content);
438            for attr in &attrs {
439                link_defs(
440                    &mut edges,
441                    &mut rel,
442                    f,
443                    attr,
444                    attribute_weight,
445                    reverse_factor,
446                    &name_to_defs,
447                    &name_def_files,
448                );
449            }
450
451            for ns in &self_ns {
452                if let Some(targets) = ns_to_frags.get(ns) {
453                    for tgt in targets {
454                        if tgt != &f.id {
455                            add_edge(&mut edges, &f.id, tgt, same_ns_weight, reverse_factor);
456                        }
457                    }
458                }
459            }
460        }
461
462        for f in &dn_frags {
463            let own = f.symbol_name.as_deref().map(|s| s.to_lowercase());
464            for m in extract_member_uses(&f.content) {
465                if own.as_deref() == Some(m.as_str()) {
466                    continue;
467                }
468                let Some(def_files) = member_def_files.get(&m) else {
469                    continue;
470                };
471                if def_files.len() > MAX_FILES_PER_NAME {
472                    continue;
473                }
474                let Some(defs) = member_defs.get(&m) else {
475                    continue;
476                };
477                for d in defs {
478                    let dst_path: &str = d.path.as_ref();
479                    if dst_path == f.path() || d == &f.id {
480                        continue;
481                    }
482                    if rel.confirmed(f.path(), dst_path) {
483                        add_edge(&mut edges, &f.id, d, member_weight, reverse_factor);
484                    }
485                }
486            }
487        }
488
489        for (_, frag_ids) in &partial_to_frags {
490            if frag_ids.len() < 2 {
491                continue;
492            }
493            for i in 0..frag_ids.len() {
494                for j in (i + 1)..frag_ids.len() {
495                    add_edge(
496                        &mut edges,
497                        &frag_ids[i],
498                        &frag_ids[j],
499                        partial_weight,
500                        reverse_factor,
501                    );
502                    add_edge(
503                        &mut edges,
504                        &frag_ids[j],
505                        &frag_ids[i],
506                        partial_weight,
507                        reverse_factor,
508                    );
509                }
510            }
511        }
512
513        edges
514    }
515
516    fn discover_related_files(
517        &self,
518        changed: &[PathBuf],
519        candidates: &[PathBuf],
520        repo_root: Option<&Path>,
521        file_cache: Option<&FxHashMap<PathBuf, String>>,
522    ) -> Vec<PathBuf> {
523        let dn_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_dotnet_file(f)).collect();
524        if dn_changed.is_empty() {
525            return vec![];
526        }
527
528        let mut all_refs = FxHashSet::default();
529        for f in &dn_changed {
530            let content = base::read_file_cached(f, file_cache);
531            if let Some(c) = content {
532                all_refs.extend(extract_usings(&c, f));
533                all_refs.extend(extract_namespaces(&c));
534                for bt in extract_base_types(&c) {
535                    all_refs.insert(bt);
536                }
537            }
538        }
539
540        discover_files_by_refs(&all_refs, changed, candidates, repo_root)
541    }
542}