Skip to main content

tokmd_analysis/
source_complexity.rs

1//! Source-level complexity helpers shared by review-oriented surfaces.
2//!
3//! These helpers are intentionally lightweight and heuristic. They preserve
4//! function-scoped Rust complexity for cockpit review gates without pulling in
5//! the full analysis preset pipeline or changing receipt schemas.
6
7#[path = "source_complexity/mask.rs"]
8mod mask;
9
10use mask::RustCodeMask;
11
12/// Summary of function-scoped Rust complexity for one source file.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct RustFunctionComplexitySummary {
15    /// Total cyclomatic complexity across all detected functions.
16    pub total_complexity: u32,
17    /// Maximum complexity of any single detected function.
18    pub max_complexity: u32,
19    /// Number of functions detected in the source file.
20    pub function_count: usize,
21    /// Maximum detected function length in lines.
22    pub max_function_length: usize,
23}
24
25/// Testable source analyzer seam for review and gate callers.
26pub trait SourceAnalyzer {
27    /// Analyze function-scoped Rust complexity for one source file.
28    fn analyze_rust(&self, content: &str) -> RustFunctionComplexitySummary;
29}
30
31/// Heuristic Rust analyzer used by cockpit review gates.
32#[derive(Debug, Default, Clone, Copy)]
33pub struct RustAnalyzer;
34
35impl SourceAnalyzer for RustAnalyzer {
36    fn analyze_rust(&self, content: &str) -> RustFunctionComplexitySummary {
37        analyze_rust_function_complexity(content)
38    }
39}
40
41/// Analyze function-scoped cyclomatic complexity of Rust source code.
42///
43/// The keyword list intentionally omits `else if`: an `else if` branch already
44/// contains `if`, and counting both terms double-counts a single decision.
45pub fn analyze_rust_function_complexity(content: &str) -> RustFunctionComplexitySummary {
46    let mut total_complexity: u32 = 0;
47    let mut max_complexity: u32 = 0;
48    let mut function_count: usize = 0;
49    let mut max_function_length: usize = 0;
50
51    let mut in_function = false;
52    let mut brace_depth: i32 = 0;
53    let mut function_brace_depth: i32 = 0;
54    let mut function_start_line: usize = 0;
55    let mut current_complexity: u32 = 1;
56    let mut mask = RustCodeMask::default();
57
58    for (line_idx, line) in content.lines().enumerate() {
59        let code_line = mask.code_only_line(line);
60        let trimmed = code_line.trim();
61
62        if trimmed.is_empty() {
63            continue;
64        }
65
66        let is_fn_start = !in_function && is_rust_fn_start(trimmed);
67
68        if is_fn_start {
69            in_function = true;
70            function_start_line = line_idx;
71            function_brace_depth = brace_depth;
72            current_complexity = 1;
73        }
74
75        for c in code_line.chars() {
76            if c == '{' {
77                brace_depth += 1;
78            } else if c == '}' {
79                brace_depth -= 1;
80                if in_function && brace_depth == function_brace_depth {
81                    let function_length = line_idx - function_start_line + 1;
82                    max_function_length = max_function_length.max(function_length);
83                    total_complexity += current_complexity;
84                    max_complexity = max_complexity.max(current_complexity);
85                    function_count += 1;
86                    in_function = false;
87                    current_complexity = 1;
88                }
89            }
90        }
91
92        if in_function {
93            for kw in ["if ", "while ", "for ", "loop ", "match ", "&&", "||", "?"] {
94                let mut search_line = trimmed;
95                while let Some(pos) = search_line.find(kw) {
96                    current_complexity += 1;
97                    search_line = &search_line[pos + kw.len()..];
98                }
99            }
100
101            current_complexity += trimmed.matches("=>").count() as u32;
102        }
103    }
104
105    if in_function {
106        function_count += 1;
107        total_complexity += current_complexity;
108        max_complexity = max_complexity.max(current_complexity);
109    }
110
111    RustFunctionComplexitySummary {
112        total_complexity,
113        max_complexity,
114        function_count,
115        max_function_length,
116    }
117}
118
119fn is_rust_fn_start(trimmed: &str) -> bool {
120    let Some(fn_pos) = trimmed.find("fn ") else {
121        return false;
122    };
123
124    let mut rest = trimmed[..fn_pos].trim();
125    if rest.is_empty() {
126        return true;
127    }
128
129    while !rest.is_empty() {
130        rest = rest.trim_start();
131        if rest.is_empty() {
132            break;
133        }
134        if rest.starts_with("pub(") {
135            let Some(close) = rest.find(')') else {
136                return false;
137            };
138            rest = &rest[close + 1..];
139        } else if let Some(next) = rest.strip_prefix("pub") {
140            rest = next;
141        } else if let Some(next) = rest.strip_prefix("async") {
142            rest = next;
143        } else if let Some(next) = rest.strip_prefix("unsafe") {
144            rest = next;
145        } else if let Some(next) = rest.strip_prefix("const") {
146            rest = next;
147        } else if rest.starts_with("extern") {
148            rest = rest["extern".len()..].trim_start();
149            if rest.starts_with('"') {
150                let Some(close) = rest[1..].find('"') else {
151                    return false;
152                };
153                rest = &rest[close + 2..];
154            }
155        } else {
156            return false;
157        }
158    }
159
160    true
161}
162
163#[cfg(test)]
164mod tests {
165    use super::{RustAnalyzer, SourceAnalyzer, analyze_rust_function_complexity};
166
167    #[test]
168    fn rust_complexity_counts_else_if_once() {
169        let analysis = analyze_rust_function_complexity(
170            r#"
171fn branchy(x: i32) -> i32 {
172    if x > 0 {
173        1
174    } else if x < 0 {
175        -1
176    } else if x == 0 {
177        0
178    } else {
179        42
180    }
181}
182"#,
183        );
184
185        assert_eq!(analysis.function_count, 1);
186        assert_eq!(analysis.total_complexity, 4);
187        assert_eq!(analysis.max_complexity, 4);
188    }
189
190    #[test]
191    fn rust_complexity_ignores_decisions_in_strings_and_comments() {
192        let analysis = analyze_rust_function_complexity(
193            r###"
194fn only_real_branch(flag: bool) {
195    let _normal = "if while for loop match && || ? => { }";
196    let _raw = r##"if while for loop match && || ? => { }"##;
197    let _char = '?';
198    /* if outer /* while nested */ match ignored => */
199    if flag {
200        println!("ok"); // else if ignored && ||
201    }
202}
203"###,
204        );
205
206        assert_eq!(analysis.function_count, 1);
207        assert_eq!(analysis.total_complexity, 2);
208        assert_eq!(analysis.max_complexity, 2);
209    }
210
211    #[test]
212    fn rust_complexity_counts_code_before_trailing_comment() {
213        let analysis = analyze_rust_function_complexity(
214            r#"
215fn branch_before_comment(flag: bool) {
216    if flag { return; } // if ignored && ||
217}
218"#,
219        );
220
221        assert_eq!(analysis.function_count, 1);
222        assert_eq!(analysis.total_complexity, 2);
223        assert_eq!(analysis.max_complexity, 2);
224    }
225
226    #[test]
227    fn rust_complexity_counts_match_arms() {
228        let analysis = analyze_rust_function_complexity(
229            r#"
230fn classify(x: i32) -> i32 {
231    match x {
232        0 => 0,
233        1 => 1,
234        _ => 2,
235    }
236}
237"#,
238        );
239
240        assert_eq!(analysis.function_count, 1);
241        assert_eq!(analysis.max_complexity, 5);
242    }
243
244    #[test]
245    fn rust_analyzer_trait_delegates_to_function_scoped_analysis() {
246        let analyzer = RustAnalyzer;
247        let analysis = analyzer.analyze_rust(
248            r#"
249fn first() {
250    if true {}
251}
252
253fn second() {
254    while false {}
255    for _ in 0..1 {}
256}
257"#,
258        );
259
260        assert_eq!(analysis.function_count, 2);
261        assert_eq!(analysis.total_complexity, 5);
262        assert_eq!(analysis.max_complexity, 3);
263    }
264}