Skip to main content

cha_core/plugins/
message_chain.rs

1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3/// Detect deep method chain calls (e.g. a.b().c().d()).
4pub struct MessageChainAnalyzer {
5    pub max_depth: usize,
6}
7
8impl Default for MessageChainAnalyzer {
9    fn default() -> Self {
10        Self { max_depth: 3 }
11    }
12}
13
14impl Plugin for MessageChainAnalyzer {
15    fn name(&self) -> &str {
16        "message_chain"
17    }
18
19    fn smells(&self) -> Vec<String> {
20        vec!["message_chain".into()]
21    }
22
23    fn description(&self) -> &str {
24        "Deep field access chains (a.b.c.d)"
25    }
26
27    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
28        let lines: Vec<&str> = ctx.file.content.lines().collect();
29        ctx.model
30            .functions
31            .iter()
32            .filter(|f| f.chain_depth > self.max_depth)
33            .map(|f| {
34                let loc = find_deepest_chain(&lines, f.start_line, f.end_line, self.max_depth)
35                    .unwrap_or((f.start_line, f.name_col, f.name_end_col));
36                Finding {
37                    smell_name: "message_chain".into(),
38                    category: SmellCategory::Couplers,
39                    severity: Severity::Warning,
40                    location: Location {
41                        path: ctx.file.path.clone(),
42                        start_line: loc.0,
43                        start_col: loc.1,
44                        end_line: loc.0,
45                        end_col: loc.2,
46                        name: Some(f.name.clone()),
47                    },
48                    message: format!(
49                        "Function `{}` has chain depth {} (threshold: {})",
50                        f.name, f.chain_depth, self.max_depth
51                    ),
52                    suggested_refactorings: vec!["Hide Delegate".into()],
53                    actual_value: Some(f.chain_depth as f64),
54                    threshold: Some(self.max_depth as f64),
55                }
56            })
57            .collect()
58    }
59}
60
61/// Scan function body for the first line where a dot-chain reaches at least
62/// `min_depth + 1` segments (matching the parser's `chain_depth > max_depth`
63/// semantics). Return `(line, start_col, end_col)` covering the chain.
64fn find_deepest_chain(
65    lines: &[&str],
66    start: usize,
67    end: usize,
68    min_depth: usize,
69) -> Option<(usize, usize, usize)> {
70    for (idx, line) in lines
71        .iter()
72        .enumerate()
73        .take(end.min(lines.len()))
74        .skip(start.saturating_sub(1))
75    {
76        if let Some((col, chain_end)) = longest_chain(line, min_depth + 1) {
77            return Some((idx + 1, col, chain_end));
78        }
79    }
80    None
81}
82
83/// Return `(start_col, end_col)` of the first chain on `line` that has at
84/// least `min_segments` identifier segments separated by dots. Skips comment
85/// lines.
86fn longest_chain(line: &str, min_segments: usize) -> Option<(usize, usize)> {
87    if is_comment_line(line) {
88        return None;
89    }
90    let bytes = line.as_bytes();
91    let mut i = 0;
92    while i < bytes.len() {
93        if is_chain_start(bytes, i) {
94            let (end, segments) = walk_chain(bytes, i);
95            if segments >= min_segments {
96                return Some((i, end));
97            }
98            i = end.max(i + 1);
99        } else {
100            i += 1;
101        }
102    }
103    None
104}
105
106fn is_comment_line(line: &str) -> bool {
107    let t = line.trim_start();
108    t.starts_with("//") || t.starts_with('#') || t.starts_with("/*")
109}
110
111fn is_chain_start(bytes: &[u8], i: usize) -> bool {
112    is_ident_start(bytes[i]) && !bytes[i].is_ascii_digit()
113}
114
115/// Walk one chain starting at `i`. Returns `(end_col, segment_count)`.
116fn walk_chain(bytes: &[u8], start: usize) -> (usize, usize) {
117    let mut cur = start;
118    let mut segments = 0;
119    loop {
120        cur = advance_ident(bytes, cur);
121        segments += 1;
122        cur = skip_optional_call(bytes, cur);
123        if !advance_dot_if_chain(bytes, &mut cur) {
124            break;
125        }
126    }
127    (cur, segments)
128}
129
130fn advance_ident(bytes: &[u8], from: usize) -> usize {
131    let mut cur = from;
132    while cur < bytes.len() && is_ident_cont(bytes[cur]) {
133        cur += 1;
134    }
135    cur
136}
137
138fn skip_optional_call(bytes: &[u8], from: usize) -> usize {
139    if from < bytes.len()
140        && bytes[from] == b'('
141        && let Some(e) = match_paren_end(bytes, from)
142    {
143        return e + 1;
144    }
145    from
146}
147
148/// If `bytes[*cur]` is `.` followed by an identifier, advance past the dot
149/// and return true (continue chain). Otherwise leave `cur` and return false.
150fn advance_dot_if_chain(bytes: &[u8], cur: &mut usize) -> bool {
151    if *cur < bytes.len()
152        && bytes[*cur] == b'.'
153        && *cur + 1 < bytes.len()
154        && is_ident_start(bytes[*cur + 1])
155    {
156        *cur += 1;
157        return true;
158    }
159    false
160}
161
162fn is_ident_start(b: u8) -> bool {
163    b.is_ascii_alphabetic() || b == b'_'
164}
165
166fn is_ident_cont(b: u8) -> bool {
167    b.is_ascii_alphanumeric() || b == b'_'
168}
169
170fn match_paren_end(bytes: &[u8], open: usize) -> Option<usize> {
171    let mut depth = 0;
172    for (i, &b) in bytes.iter().enumerate().skip(open) {
173        if b == b'(' {
174            depth += 1;
175        } else if b == b')' {
176            depth -= 1;
177            if depth == 0 {
178                return Some(i);
179            }
180        }
181    }
182    None
183}