cha_core/plugins/
inappropriate_intimacy.rs1use std::collections::HashMap;
2
3use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
4
5pub struct InappropriateIntimacyAnalyzer;
8
9impl Default for InappropriateIntimacyAnalyzer {
10 fn default() -> Self {
11 Self
12 }
13}
14
15impl Plugin for InappropriateIntimacyAnalyzer {
16 fn name(&self) -> &str {
17 "inappropriate_intimacy"
18 }
19
20 fn description(&self) -> &str {
21 "Bidirectional imports between files"
22 }
23
24 fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
25 let current = normalize_path(&ctx.file.path.to_string_lossy());
26 let mut checked: HashMap<String, Vec<String>> = HashMap::new();
27 ctx.model
28 .imports
29 .iter()
30 .filter_map(|imp| {
31 let target = resolve_import(&ctx.file.path.to_string_lossy(), &imp.source);
32 if target.is_empty() {
33 return None;
34 }
35 let reverse = checked
36 .entry(target.clone())
37 .or_insert_with(|| read_file_imports(&target));
38 let has_cycle = reverse
39 .iter()
40 .any(|ri| normalize_path(&resolve_import(&target, ri)) == current);
41 has_cycle.then(|| make_finding(ctx, imp, ¤t))
42 })
43 .collect()
44 }
45}
46
47fn make_finding(ctx: &AnalysisContext, imp: &crate::ImportInfo, current: &str) -> Finding {
48 Finding {
49 smell_name: "inappropriate_intimacy".into(),
50 category: SmellCategory::Couplers,
51 severity: Severity::Warning,
52 location: Location {
53 path: ctx.file.path.clone(),
54 start_line: imp.line,
55 start_col: imp.col,
56 end_line: imp.line,
57 name: None,
58 ..Default::default()
59 },
60 message: format!(
61 "Bidirectional dependency between `{}` and `{}`, consider Move Method or Hide Delegate",
62 current, imp.source
63 ),
64 suggested_refactorings: vec!["Move Method".into(), "Hide Delegate".into()],
65 ..Default::default()
66 }
67}
68
69fn normalize_path(p: &str) -> String {
70 p.replace('\\', "/").trim_start_matches("./").to_string()
71}
72
73fn normalize_import(source: &str) -> String {
74 source
75 .trim_matches('"')
76 .trim_matches('\'')
77 .replace('\\', "/")
78 .to_string()
79}
80
81fn resolve_import(base: &str, import: &str) -> String {
83 if !import.starts_with('.') {
84 return String::new(); }
86 let base_dir = std::path::Path::new(base)
87 .parent()
88 .unwrap_or(std::path::Path::new(""));
89 let resolved = base_dir.join(import);
90 for ext in &["", ".ts", ".tsx", ".rs"] {
92 let with_ext = format!("{}{}", resolved.to_string_lossy(), ext);
93 if std::path::Path::new(&with_ext).exists() {
94 return normalize_path(&with_ext);
95 }
96 }
97 normalize_path(&resolved.to_string_lossy())
98}
99
100fn read_file_imports(path: &str) -> Vec<String> {
102 let content = match std::fs::read_to_string(path) {
103 Ok(c) => c,
104 Err(_) => return vec![],
105 };
106 content
107 .lines()
108 .filter_map(|line| {
109 let trimmed = line.trim();
110 if trimmed.starts_with("import")
112 && let Some(from_idx) = trimmed.find("from")
113 {
114 let rest = trimmed[from_idx + 4..].trim().trim_matches(';');
115 return Some(normalize_import(rest));
116 }
117 None
118 })
119 .collect()
120}