cha_core/plugins/
message_chain.rs1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3pub 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
61fn 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
83fn 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
115fn 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
148fn 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}