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
16const 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 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}