Skip to main content

lean_ctx/core/
cyclomatic.rs

1//! Cyclomatic complexity via tree-sitter (decision-point counting).
2//!
3//! Uses the same structural chunk roots as [`super::chunks_ts`] and walks each function-like
4//! subtree, skipping nested function bodies so inner items get their own scores.
5
6use serde::Serialize;
7
8#[cfg(feature = "tree-sitter")]
9use tree_sitter::Node;
10
11/// McCabe-style complexity for one function-like root (minimum 1).
12#[derive(Debug, Clone, PartialEq, Serialize)]
13pub struct FunctionComplexity {
14    pub name: String,
15    /// 1-based start line of this function-like node.
16    pub line: usize,
17    pub cyclomatic: u32,
18}
19
20/// AST-backed cyclomatic complexity for every function-like node under structural chunks.
21///
22/// Returns `None` when tree-sitter is disabled or `extension` is unsupported.
23pub fn cyclomatic_per_function(source: &str, extension: &str) -> Option<Vec<FunctionComplexity>> {
24    #[cfg(feature = "tree-sitter")]
25    {
26        cyclomatic_per_function_impl(source, extension)
27    }
28    #[cfg(not(feature = "tree-sitter"))]
29    {
30        let _ = (source, extension);
31        None
32    }
33}
34
35#[cfg(feature = "tree-sitter")]
36fn cyclomatic_per_function_impl(source: &str, extension: &str) -> Option<Vec<FunctionComplexity>> {
37    let mut out = Vec::new();
38    let src_bytes = source.as_bytes();
39
40    super::chunks_ts::for_each_chunk_node(
41        source,
42        extension,
43        |chunk_root, _chunk_name, _kind, _, _| {
44            let mut fn_nodes = Vec::new();
45            crate::core::ast_walk::for_each_descendant(chunk_root, |node| {
46                if is_fn_like(node.kind()) {
47                    fn_nodes.push(node);
48                }
49            });
50
51            for fn_node in fn_nodes {
52                let name = fn_name(fn_node, src_bytes).unwrap_or_else(|| "<anonymous>".to_string());
53                let cyclomatic = cyclomatic_for_fn_like(fn_node, src_bytes, extension);
54                let fn_line = fn_node.start_position().row.saturating_add(1);
55                out.push(FunctionComplexity {
56                    name,
57                    line: fn_line,
58                    cyclomatic,
59                });
60            }
61        },
62    )?;
63
64    if out.is_empty() {
65        None
66    } else {
67        Some(out)
68    }
69}
70
71#[cfg(feature = "tree-sitter")]
72fn is_fn_like(kind: &str) -> bool {
73    matches!(
74        kind,
75        "function_item"
76            | "function_declaration"
77            | "function_definition"
78            | "closure_expression"
79            | "arrow_function"
80            | "method_definition"
81            | "method_declaration"
82            | "constructor_declaration"
83            | "lambda"
84            | "func_literal"
85    )
86}
87
88#[cfg(feature = "tree-sitter")]
89fn fn_name(node: Node, source: &[u8]) -> Option<String> {
90    let mut cursor = node.walk();
91    for child in node.children(&mut cursor) {
92        match child.kind() {
93            "identifier" | "type_identifier" | "property_identifier" | "field_identifier" => {
94                if let Ok(t) = child.utf8_text(source) {
95                    return Some(t.to_string());
96                }
97            }
98            _ => {}
99        }
100    }
101    None
102}
103
104#[cfg(feature = "tree-sitter")]
105fn logical_body_root(fn_like: Node<'_>) -> Node<'_> {
106    fn_like
107        .child_by_field_name("body")
108        .or_else(|| fn_like.child_by_field_name("value"))
109        .unwrap_or(fn_like)
110}
111
112#[cfg(feature = "tree-sitter")]
113fn cyclomatic_for_fn_like(fn_node: Node, source: &[u8], ext: &str) -> u32 {
114    let root = logical_body_root(fn_node);
115    1 + count_decisions_skip_nested_fn(root, source, ext)
116}
117
118#[cfg(feature = "tree-sitter")]
119fn count_decisions_skip_nested_fn(root: Node, source: &[u8], ext: &str) -> u32 {
120    // Iterative (heap-stack) walk that prunes nested function subtrees so they
121    // are scored independently. Heap stack avoids the #378 SIGABRT on deep ASTs.
122    let mut sum = 0;
123    crate::core::ast_walk::for_each_descendant_pruned(root, |node| {
124        if node != root && skip_nested_fn_root(node) {
125            return false;
126        }
127        sum += tally_decision(node, source, ext);
128        true
129    });
130    sum
131}
132
133#[cfg(feature = "tree-sitter")]
134fn skip_nested_fn_root(node: Node) -> bool {
135    is_fn_like(node.kind())
136}
137
138#[cfg(feature = "tree-sitter")]
139fn tally_decision(node: Node, source: &[u8], ext: &str) -> u32 {
140    match node.kind() {
141        "if_statement"
142        | "if_expression"
143        | "while_statement"
144        | "while_expression"
145        | "for_statement"
146        | "for_expression"
147        | "do_statement"
148        | "loop_expression"
149        | "case_statement"
150        | "switch_case"
151        | "switch_rule"
152        | "catch_clause"
153        | "except_clause"
154        | "conditional_expression"
155        | "ternary_expression" => 1,
156        "match_arm" => u32::from(matches!(ext, "rs")),
157        "boolean_operator" => python_boolean_operator(node, source),
158        "binary_expression" => binary_boolean_shortcircuit(node, source),
159        _ => 0,
160    }
161}
162
163#[cfg(feature = "tree-sitter")]
164fn python_boolean_operator(node: Node, source: &[u8]) -> u32 {
165    let mut cursor = node.walk();
166    for child in node.children(&mut cursor) {
167        if let Ok(t) = child.utf8_text(source) {
168            if t == "and" || t == "or" {
169                return 1;
170            }
171        }
172    }
173    0
174}
175
176#[cfg(feature = "tree-sitter")]
177fn binary_boolean_shortcircuit(node: Node, source: &[u8]) -> u32 {
178    node.child_by_field_name("operator")
179        .and_then(|op| op.utf8_text(source).ok())
180        .map_or(0, |t| u32::from(matches!(t, "&&" | "||" | "and" | "or")))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[cfg(feature = "tree-sitter")]
188    #[test]
189    fn cyclomatic_counts_branches_rust() {
190        let src = r"pub fn f(x: i32) -> i32 {
191    if x > 0 {
192        1
193    } else if x < 0 {
194        -1
195    } else {
196        0
197    }
198}";
199        let v = cyclomatic_per_function(src, "rs").expect("parse");
200        let f = v.iter().find(|e| e.name == "f").expect("fn f");
201        assert!(
202            f.cyclomatic >= 3,
203            "expected >=3 (McCabe paths), got {}",
204            f.cyclomatic
205        );
206    }
207
208    #[cfg(feature = "tree-sitter")]
209    #[test]
210    fn cyclomatic_match_arms_rust() {
211        let src = r"pub fn g(e: u8) -> u8 {
212    match e {
213        0 => 0,
214        1 => 1,
215        _ => 2,
216    }
217}";
218        let v = cyclomatic_per_function(src, "rs").expect("parse");
219        let g = v.iter().find(|e| e.name == "g").expect("fn g");
220        assert!(g.cyclomatic >= 4, "match + arms: got {}", g.cyclomatic);
221    }
222
223    #[cfg(not(feature = "tree-sitter"))]
224    #[test]
225    fn cyclomatic_disabled_returns_none() {
226        assert!(cyclomatic_per_function("fn a() {}", "rs").is_none());
227    }
228}