Skip to main content

lean_ctx/core/code_health/
cognitive.rs

1//! Cognitive complexity (SonarQube **S3776**-style) via tree-sitter.
2//!
3//! Cyclomatic complexity counts independent paths (good for "how many tests");
4//! cognitive complexity models how hard code is to *follow* by adding a
5//! **nesting penalty**, so deeply nested control flow scores higher than flat
6//! code with the same number of branches. That nesting is the signal the Sonar
7//! study links to agent token cost: a deeply nested function cannot be
8//! navigated by name, so the agent reads all of it.
9//!
10//! ## Increment rules
11//! - **+1 plus the current nesting depth** for each control-flow construct that
12//!   nests: `if`, loops, `switch`/`match`, `catch`/`except`, ternary, `try`.
13//!   Each such construct also raises the nesting level for its body.
14//! - **+1 (flat)** for each *sequence* of binary boolean operators
15//!   (`&&`/`||`/`and`/`or`) — consecutive identical operators count once.
16//! - **+1 (flat)** for flow-breaking jumps that carry a label (`break`/
17//!   `continue` with a label) and for `goto`.
18//! - `else` / `else if` do **not** add a nesting level (handled via the
19//!   `alternative` field), so else-if chains stay roughly linear.
20//!
21//! Nested function bodies are scored independently (mirrors
22//! [`crate::core::cyclomatic`]). The traversal uses the heap-stack walk pattern
23//! to stay safe on pathologically deep trees (#378).
24
25use serde::Serialize;
26
27/// Cognitive complexity of a single function-like definition.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct FunctionCognitive {
30    pub name: String,
31    /// 1-based start line of the function.
32    pub line: usize,
33    /// 1-based end line of the function (for span/token estimates).
34    pub end_line: usize,
35    pub cognitive: u32,
36}
37
38impl FunctionCognitive {
39    /// Number of source lines the function spans (at least 1).
40    pub fn line_span(&self) -> usize {
41        self.end_line.saturating_sub(self.line).saturating_add(1)
42    }
43}
44
45/// Compute cognitive complexity per function for `source` of the given file
46/// `extension`. Returns `None` when tree-sitter is disabled, the extension is
47/// unsupported, or the file has no functions.
48pub 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        // Deterministic order independent of traversal: by line then name.
79        out.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.name.cmp(&b.name)));
80        Some(out)
81    }
82}
83
84/// Classification of a node's contribution to cognitive complexity.
85#[cfg(feature = "tree-sitter")]
86#[derive(Clone, Copy, PartialEq, Eq)]
87enum Incr {
88    /// No contribution.
89    None,
90    /// +1 with no nesting penalty and no nesting increase (boolean ops, jumps).
91    Flat,
92    /// +1 plus the current nesting depth; raises nesting for the body.
93    Nesting,
94}
95
96/// Sum cognitive complexity over a function body, skipping nested function
97/// definitions (they are scored separately). Order-independent: the result is a
98/// pure sum, so the heap-stack traversal needs no ordering guarantees.
99#[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        // Nested functions form their own scope and are scored on their own.
106        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        // `else`/`else if` must not deepen nesting: the `alternative` branch of
122        // an `if` stays at the parent's level so else-if chains remain linear.
123        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    // A boolean operator counts once per sequence: skip it when its parent is
149    // the same logical operator (e.g. the inner `&&` of `a && b && c`).
150    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/// Control-flow constructs that increment *and* raise nesting.
168#[cfg(feature = "tree-sitter")]
169fn is_nesting_kind(kind: &str) -> bool {
170    matches!(
171        kind,
172        // conditionals
173        "if_statement"
174            | "if_expression"
175            | "conditional_expression"
176            | "ternary_expression"
177            // loops
178            | "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            // multi-way branches (the container counts once, not each arm)
189            | "switch_statement"
190            | "switch_expression"
191            | "match_expression"
192            | "match_statement"
193            // exception handlers
194            | "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/// Returns the canonical operator string if `node` is a binary boolean
207/// operator, else `None`. Handles Python's keyword form and the symbolic form
208/// used by Rust/JS/TS/Go/Java/C/C++.
209#[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            // Python: the operator is a keyword child token.
214            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/// Labeled `break`/`continue` and `goto` break linear reading flow → +1.
237#[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        // Three `if`s at the same level (else-if does not deepen nesting).
286        assert_eq!(cc, 3);
287    }
288
289    #[test]
290    fn boolean_sequence_counts_once_per_operator() {
291        // `a && b && c` is one &&-sequence → +1; mixing in `||` adds another.
292        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        // outer sees only its own `if a` (the nested fn body is excluded).
302        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        // if a → +1, nested if b → +2.
310        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}