lean_ctx/core/
cyclomatic.rs1use serde::Serialize;
7
8#[cfg(feature = "tree-sitter")]
9use tree_sitter::Node;
10
11#[derive(Debug, Clone, PartialEq, Serialize)]
13pub struct FunctionComplexity {
14 pub name: String,
15 pub line: usize,
17 pub cyclomatic: u32,
18}
19
20pub 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 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}