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            collect_fn_like_nodes(chunk_root, &mut fn_nodes);
46
47            for fn_node in fn_nodes {
48                let name = fn_name(fn_node, src_bytes).unwrap_or_else(|| "<anonymous>".to_string());
49                let cyclomatic = cyclomatic_for_fn_like(fn_node, src_bytes, extension);
50                let fn_line = fn_node.start_position().row.saturating_add(1);
51                out.push(FunctionComplexity {
52                    name,
53                    line: fn_line,
54                    cyclomatic,
55                });
56            }
57        },
58    )?;
59
60    if out.is_empty() {
61        None
62    } else {
63        Some(out)
64    }
65}
66
67#[cfg(feature = "tree-sitter")]
68fn collect_fn_like_nodes<'a>(node: Node<'a>, out: &mut Vec<Node<'a>>) {
69    if is_fn_like(node.kind()) {
70        out.push(node);
71    }
72    let mut cursor = node.walk();
73    for child in node.children(&mut cursor) {
74        collect_fn_like_nodes(child, out);
75    }
76}
77
78#[cfg(feature = "tree-sitter")]
79fn is_fn_like(kind: &str) -> bool {
80    matches!(
81        kind,
82        "function_item"
83            | "function_declaration"
84            | "function_definition"
85            | "closure_expression"
86            | "arrow_function"
87            | "method_definition"
88            | "method_declaration"
89            | "constructor_declaration"
90            | "lambda"
91            | "func_literal"
92    )
93}
94
95#[cfg(feature = "tree-sitter")]
96fn fn_name(node: Node, source: &[u8]) -> Option<String> {
97    let mut cursor = node.walk();
98    for child in node.children(&mut cursor) {
99        match child.kind() {
100            "identifier" | "type_identifier" | "property_identifier" | "field_identifier" => {
101                if let Ok(t) = child.utf8_text(source) {
102                    return Some(t.to_string());
103                }
104            }
105            _ => {}
106        }
107    }
108    None
109}
110
111#[cfg(feature = "tree-sitter")]
112fn logical_body_root(fn_like: Node<'_>) -> Node<'_> {
113    fn_like
114        .child_by_field_name("body")
115        .or_else(|| fn_like.child_by_field_name("value"))
116        .unwrap_or(fn_like)
117}
118
119#[cfg(feature = "tree-sitter")]
120fn cyclomatic_for_fn_like(fn_node: Node, source: &[u8], ext: &str) -> u32 {
121    let root = logical_body_root(fn_node);
122    1 + count_decisions_skip_nested_fn(root, source, ext)
123}
124
125#[cfg(feature = "tree-sitter")]
126fn count_decisions_skip_nested_fn(node: Node, source: &[u8], ext: &str) -> u32 {
127    let mut sum = tally_decision(node, source, ext);
128    let mut cursor = node.walk();
129    for child in node.children(&mut cursor) {
130        if skip_nested_fn_root(child) {
131            continue;
132        }
133        sum += count_decisions_skip_nested_fn(child, source, ext);
134    }
135    sum
136}
137
138#[cfg(feature = "tree-sitter")]
139fn skip_nested_fn_root(node: Node) -> bool {
140    is_fn_like(node.kind())
141}
142
143#[cfg(feature = "tree-sitter")]
144fn tally_decision(node: Node, source: &[u8], ext: &str) -> u32 {
145    match node.kind() {
146        "if_statement"
147        | "if_expression"
148        | "while_statement"
149        | "while_expression"
150        | "for_statement"
151        | "for_expression"
152        | "do_statement"
153        | "loop_expression"
154        | "case_statement"
155        | "switch_case"
156        | "switch_rule"
157        | "catch_clause"
158        | "except_clause"
159        | "conditional_expression"
160        | "ternary_expression" => 1,
161        "match_arm" => u32::from(matches!(ext, "rs")),
162        "boolean_operator" => python_boolean_operator(node, source),
163        "binary_expression" => binary_boolean_shortcircuit(node, source),
164        _ => 0,
165    }
166}
167
168#[cfg(feature = "tree-sitter")]
169fn python_boolean_operator(node: Node, source: &[u8]) -> u32 {
170    let mut cursor = node.walk();
171    for child in node.children(&mut cursor) {
172        if let Ok(t) = child.utf8_text(source) {
173            if t == "and" || t == "or" {
174                return 1;
175            }
176        }
177    }
178    0
179}
180
181#[cfg(feature = "tree-sitter")]
182fn binary_boolean_shortcircuit(node: Node, source: &[u8]) -> u32 {
183    node.child_by_field_name("operator")
184        .and_then(|op| op.utf8_text(source).ok())
185        .map_or(0, |t| u32::from(matches!(t, "&&" | "||" | "and" | "or")))
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[cfg(feature = "tree-sitter")]
193    #[test]
194    fn cyclomatic_counts_branches_rust() {
195        let src = r"pub fn f(x: i32) -> i32 {
196    if x > 0 {
197        1
198    } else if x < 0 {
199        -1
200    } else {
201        0
202    }
203}";
204        let v = cyclomatic_per_function(src, "rs").expect("parse");
205        let f = v.iter().find(|e| e.name == "f").expect("fn f");
206        assert!(
207            f.cyclomatic >= 3,
208            "expected >=3 (McCabe paths), got {}",
209            f.cyclomatic
210        );
211    }
212
213    #[cfg(feature = "tree-sitter")]
214    #[test]
215    fn cyclomatic_match_arms_rust() {
216        let src = r"pub fn g(e: u8) -> u8 {
217    match e {
218        0 => 0,
219        1 => 1,
220        _ => 2,
221    }
222}";
223        let v = cyclomatic_per_function(src, "rs").expect("parse");
224        let g = v.iter().find(|e| e.name == "g").expect("fn g");
225        assert!(g.cyclomatic >= 4, "match + arms: got {}", g.cyclomatic);
226    }
227
228    #[cfg(not(feature = "tree-sitter"))]
229    #[test]
230    fn cyclomatic_disabled_returns_none() {
231        assert!(cyclomatic_per_function("fn a() {}", "rs").is_none());
232    }
233}