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