Skip to main content

cha_core/plugins/
inappropriate_intimacy.rs

1use std::collections::HashMap;
2
3use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
4
5/// Detect bidirectional imports between files (inappropriate intimacy).
6/// Requires multi-file context: accumulates import data across files.
7pub 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, &current))
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
81/// Resolve a relative import path against a base file path.
82fn resolve_import(base: &str, import: &str) -> String {
83    if !import.starts_with('.') {
84        return String::new(); // skip non-relative imports
85    }
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    // Try common extensions
91    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
100/// Read import sources from a file (lightweight grep, no full parse).
101fn 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            // Match: import ... from "..." or use ...;
111            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}