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 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, &current))
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
85/// Resolve a relative import path against a base file path.
86fn resolve_import(base: &str, import: &str) -> String {
87    if !import.starts_with('.') {
88        return String::new(); // skip non-relative imports
89    }
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    // Try common extensions
95    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
104/// Read import sources from a file (lightweight grep, no full parse).
105fn 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            // Match: import ... from "..." or use ...;
115            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}