lean_ctx/core/code_health/
cognitive.rs1use serde::Serialize;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct FunctionCognitive {
30 pub name: String,
31 pub line: usize,
33 pub end_line: usize,
35 pub cognitive: u32,
36}
37
38impl FunctionCognitive {
39 pub fn line_span(&self) -> usize {
41 self.end_line.saturating_sub(self.line).saturating_add(1)
42 }
43}
44
45pub fn cognitive_per_function(source: &str, extension: &str) -> Option<Vec<FunctionCognitive>> {
49 #[cfg(feature = "tree-sitter")]
50 {
51 cognitive_impl(source, extension)
52 }
53 #[cfg(not(feature = "tree-sitter"))]
54 {
55 let _ = (source, extension);
56 None
57 }
58}
59
60#[cfg(feature = "tree-sitter")]
61fn cognitive_impl(source: &str, extension: &str) -> Option<Vec<FunctionCognitive>> {
62 let mut out: Vec<FunctionCognitive> = Vec::new();
63 super::astutil::for_each_function(source, extension, |fn_node, name, src| {
64 let body = super::astutil::logical_body_root(fn_node);
65 let cognitive = cognitive_for_body(body, src, extension);
66 let line = fn_node.start_position().row.saturating_add(1);
67 let end_line = fn_node.end_position().row.saturating_add(1);
68 out.push(FunctionCognitive {
69 name: name.to_string(),
70 line,
71 end_line,
72 cognitive,
73 });
74 })?;
75 if out.is_empty() {
76 None
77 } else {
78 out.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.name.cmp(&b.name)));
80 Some(out)
81 }
82}
83
84#[cfg(feature = "tree-sitter")]
86#[derive(Clone, Copy, PartialEq, Eq)]
87enum Incr {
88 None,
90 Flat,
92 Nesting,
94}
95
96#[cfg(feature = "tree-sitter")]
100fn cognitive_for_body(root: tree_sitter::Node<'_>, source: &[u8], ext: &str) -> u32 {
101 let root_id = root.id();
102 let mut total: u32 = 0;
103 let mut stack: Vec<(tree_sitter::Node<'_>, u32)> = vec![(root, 0)];
104 while let Some((node, nesting)) = stack.pop() {
105 if node.id() != root_id && super::astutil::is_fn_like(node.kind()) {
107 continue;
108 }
109 let class = classify(node, source, ext);
110 match class {
111 Incr::Nesting => total = total.saturating_add(1).saturating_add(nesting),
112 Incr::Flat => total = total.saturating_add(1),
113 Incr::None => {}
114 }
115
116 let child_nesting = if class == Incr::Nesting {
117 nesting + 1
118 } else {
119 nesting
120 };
121 let alternative_id = if class == Incr::Nesting && is_if_kind(node.kind()) {
124 node.child_by_field_name("alternative").map(|n| n.id())
125 } else {
126 None
127 };
128
129 let mut cursor = node.walk();
130 for child in node.children(&mut cursor) {
131 let cn = if Some(child.id()) == alternative_id {
132 nesting
133 } else {
134 child_nesting
135 };
136 stack.push((child, cn));
137 }
138 }
139 total
140}
141
142#[cfg(feature = "tree-sitter")]
143fn classify(node: tree_sitter::Node<'_>, source: &[u8], ext: &str) -> Incr {
144 let kind = node.kind();
145 if is_nesting_kind(kind) {
146 return Incr::Nesting;
147 }
148 if let Some(op) = boolean_op_text(node, source) {
151 let parent_same = node
152 .parent()
153 .and_then(|p| boolean_op_text(p, source))
154 .is_some_and(|pop| pop == op);
155 if !parent_same {
156 return Incr::Flat;
157 }
158 return Incr::None;
159 }
160 if is_flow_break(node, source) {
161 return Incr::Flat;
162 }
163 let _ = ext;
164 Incr::None
165}
166
167#[cfg(feature = "tree-sitter")]
169fn is_nesting_kind(kind: &str) -> bool {
170 matches!(
171 kind,
172 "if_statement"
174 | "if_expression"
175 | "conditional_expression"
176 | "ternary_expression"
177 | "for_statement"
179 | "for_expression"
180 | "for_in_statement"
181 | "for_range_loop"
182 | "enhanced_for_statement"
183 | "while_statement"
184 | "while_expression"
185 | "do_statement"
186 | "loop_expression"
187 | "loop_statement"
188 | "switch_statement"
190 | "switch_expression"
191 | "match_expression"
192 | "match_statement"
193 | "catch_clause"
195 | "except_clause"
196 | "try_statement"
197 | "try_expression"
198 )
199}
200
201#[cfg(feature = "tree-sitter")]
202fn is_if_kind(kind: &str) -> bool {
203 matches!(kind, "if_statement" | "if_expression")
204}
205
206#[cfg(feature = "tree-sitter")]
210fn boolean_op_text(node: tree_sitter::Node<'_>, source: &[u8]) -> Option<&'static str> {
211 match node.kind() {
212 "boolean_operator" => {
213 let mut cursor = node.walk();
215 for child in node.children(&mut cursor) {
216 match child.utf8_text(source) {
217 Ok("and") => return Some("&&"),
218 Ok("or") => return Some("||"),
219 _ => {}
220 }
221 }
222 None
223 }
224 "binary_expression" | "binary_operator" | "logical_expression" => node
225 .child_by_field_name("operator")
226 .and_then(|op| op.utf8_text(source).ok())
227 .and_then(|t| match t {
228 "&&" | "and" => Some("&&"),
229 "||" | "or" => Some("||"),
230 _ => None,
231 }),
232 _ => None,
233 }
234}
235
236#[cfg(feature = "tree-sitter")]
238fn is_flow_break(node: tree_sitter::Node<'_>, source: &[u8]) -> bool {
239 match node.kind() {
240 "goto_statement" => true,
241 "break_statement" | "break_expression" | "continue_statement" | "continue_expression" => {
242 let mut cursor = node.walk();
243 node.children(&mut cursor).any(|child| {
244 matches!(
245 child.kind(),
246 "label" | "loop_label" | "statement_identifier" | "label_name"
247 ) && child.utf8_text(source).is_ok_and(|t| !t.is_empty())
248 })
249 }
250 _ => false,
251 }
252}
253
254#[cfg(all(test, feature = "tree-sitter"))]
255mod tests {
256 use super::*;
257
258 fn score(src: &str, ext: &str, name: &str) -> u32 {
259 cognitive_per_function(src, ext)
260 .unwrap_or_default()
261 .into_iter()
262 .find(|f| f.name == name)
263 .map_or_else(
264 || panic!("function `{name}` not found in {ext} source"),
265 |f| f.cognitive,
266 )
267 }
268
269 #[test]
270 fn nested_scores_higher_than_flat() {
271 let flat = "fn flat(a: bool, b: bool, c: bool) { if a {} if b {} if c {} }";
272 let nested = "fn nested(a: bool, b: bool, c: bool) { if a { if b { if c {} } } }";
273 let flat_cc = score(flat, "rs", "flat");
274 let nested_cc = score(nested, "rs", "nested");
275 assert_eq!(flat_cc, 3, "three top-level ifs: 1+1+1");
276 assert_eq!(nested_cc, 6, "nested ifs: 1 + 2 + 3 (nesting penalty)");
277 assert!(nested_cc > flat_cc);
278 }
279
280 #[test]
281 fn else_if_chain_stays_linear() {
282 let src =
283 "fn chain(a: bool, b: bool, c: bool) { if a {} else if b {} else if c {} else {} }";
284 let cc = score(src, "rs", "chain");
285 assert_eq!(cc, 3);
287 }
288
289 #[test]
290 fn boolean_sequence_counts_once_per_operator() {
291 let same = "fn s(a: bool, b: bool, c: bool) -> bool { a && b && c }";
293 let mixed = "fn m(a: bool, b: bool, c: bool) -> bool { a && b || c }";
294 assert_eq!(score(same, "rs", "s"), 1);
295 assert_eq!(score(mixed, "rs", "m"), 2);
296 }
297
298 #[test]
299 fn nested_function_is_scored_separately() {
300 let src = "fn outer(a: bool) { fn inner(b: bool) { if b {} } if a {} }";
301 assert_eq!(score(src, "rs", "outer"), 1);
303 assert_eq!(score(src, "rs", "inner"), 1);
304 }
305
306 #[test]
307 fn python_nesting_penalty_applies() {
308 let src = "def f(a, b):\n if a:\n if b:\n return 1\n return 0\n";
309 assert_eq!(score(src, "py", "f"), 3);
311 }
312
313 #[test]
314 fn deterministic_across_runs() {
315 let src = "fn g(a: bool, b: bool) { if a { while b { if a {} } } }";
316 let first = cognitive_per_function(src, "rs");
317 let second = cognitive_per_function(src, "rs");
318 assert_eq!(first, second);
319 }
320
321 #[test]
322 fn flat_function_is_zero() {
323 let src = "fn plain(x: i32) -> i32 { let y = x + 1; y * 2 }";
324 assert_eq!(score(src, "rs", "plain"), 0);
325 }
326}