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                    risk_score: None,
56                }
57            })
58            .collect()
59    }
60}
61
62/// Scan function body for the first line where a dot-chain reaches at least
63/// `min_depth + 1` segments (matching the parser's `chain_depth > max_depth`
64/// semantics). Return `(line, start_col, end_col)` covering the chain.
65fn find_deepest_chain(
66    lines: &[&str],
67    start: usize,
68    end: usize,
69    min_depth: usize,
70) -> Option<(usize, usize, usize)> {
71    for (idx, line) in lines
72        .iter()
73        .enumerate()
74        .take(end.min(lines.len()))
75        .skip(start.saturating_sub(1))
76    {
77        if let Some((col, chain_end)) = longest_chain(line, min_depth + 1) {
78            return Some((idx + 1, col, chain_end));
79        }
80    }
81    None
82}
83
84/// Return `(start_col, end_col)` of the first chain on `line` that has at
85/// least `min_segments` identifier segments separated by dots. Skips comment
86/// lines.
87fn longest_chain(line: &str, min_segments: usize) -> Option<(usize, usize)> {
88    if is_comment_line(line) {
89        return None;
90    }
91    let bytes = line.as_bytes();
92    let mut i = 0;
93    while i < bytes.len() {
94        if is_chain_start(bytes, i) {
95            let (end, segments) = walk_chain(bytes, i);
96            if segments >= min_segments {
97                return Some((i, end));
98            }
99            i = end.max(i + 1);
100        } else {
101            i += 1;
102        }
103    }
104    None
105}
106
107fn is_comment_line(line: &str) -> bool {
108    let t = line.trim_start();
109    t.starts_with("//") || t.starts_with('#') || t.starts_with("/*")
110}
111
112fn is_chain_start(bytes: &[u8], i: usize) -> bool {
113    is_ident_start(bytes[i]) && !bytes[i].is_ascii_digit()
114}
115
116/// Walk one chain starting at `i`. Returns `(end_col, segment_count)`.
117fn walk_chain(bytes: &[u8], start: usize) -> (usize, usize) {
118    let mut cur = start;
119    let mut segments = 0;
120    loop {
121        cur = advance_ident(bytes, cur);
122        segments += 1;
123        cur = skip_optional_call(bytes, cur);
124        if !advance_dot_if_chain(bytes, &mut cur) {
125            break;
126        }
127    }
128    (cur, segments)
129}
130
131fn advance_ident(bytes: &[u8], from: usize) -> usize {
132    let mut cur = from;
133    while cur < bytes.len() && is_ident_cont(bytes[cur]) {
134        cur += 1;
135    }
136    cur
137}
138
139fn skip_optional_call(bytes: &[u8], from: usize) -> usize {
140    if from < bytes.len()
141        && bytes[from] == b'('
142        && let Some(e) = match_paren_end(bytes, from)
143    {
144        return e + 1;
145    }
146    from
147}
148
149/// If `bytes[*cur]` is `.` followed by an identifier, advance past the dot
150/// and return true (continue chain). Otherwise leave `cur` and return false.
151fn advance_dot_if_chain(bytes: &[u8], cur: &mut usize) -> bool {
152    if *cur < bytes.len()
153        && bytes[*cur] == b'.'
154        && *cur + 1 < bytes.len()
155        && is_ident_start(bytes[*cur + 1])
156    {
157        *cur += 1;
158        return true;
159    }
160    false
161}
162
163fn is_ident_start(b: u8) -> bool {
164    b.is_ascii_alphabetic() || b == b'_'
165}
166
167fn is_ident_cont(b: u8) -> bool {
168    b.is_ascii_alphanumeric() || b == b'_'
169}
170
171fn match_paren_end(bytes: &[u8], open: usize) -> Option<usize> {
172    let mut depth = 0;
173    for (i, &b) in bytes.iter().enumerate().skip(open) {
174        if b == b'(' {
175            depth += 1;
176        } else if b == b')' {
177            depth -= 1;
178            if depth == 0 {
179                return Some(i);
180            }
181        }
182    }
183    None
184}