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 risk_score: None,
56 }
57 })
58 .collect()
59 }
60}
61
62fn 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
84fn 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
116fn 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
149fn 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}