1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::Result;
5use tokmd_analysis_maintainability::compute_maintainability_index;
6use tokmd_analysis_types::{
7 ComplexityHistogram, ComplexityReport, ComplexityRisk, FileComplexity,
8 FunctionComplexityDetail, TechnicalDebtLevel, TechnicalDebtRatio,
9};
10use tokmd_types::{ExportData, FileKind, FileRow};
11
12use tokmd_analysis_util::{AnalysisLimits, normalize_path};
13
14const DEFAULT_MAX_FILE_BYTES: u64 = 128 * 1024;
15const MAX_COMPLEXITY_FILES: usize = 100;
16const TECHNICAL_DEBT_LOW_THRESHOLD: f64 = 30.0;
17const TECHNICAL_DEBT_MODERATE_THRESHOLD: f64 = 60.0;
18const TECHNICAL_DEBT_HIGH_THRESHOLD: f64 = 100.0;
19
20fn map_language_for_complexity(lang: &str) -> &str {
22 match lang.to_lowercase().as_str() {
23 "rust" => "rust",
24 "javascript" | "jsx" => "javascript",
25 "typescript" | "tsx" => "typescript",
26 "python" => "python",
27 "go" => "go",
28 "c" => "c",
29 "c++" | "cpp" => "c++",
30 "java" => "java",
31 "c#" | "csharp" => "c#",
32 "php" => "php",
33 "ruby" => "ruby",
34 _ => lang,
35 }
36}
37
38fn is_complexity_lang(lang: &str) -> bool {
40 matches!(
41 lang.to_lowercase().as_str(),
42 "rust"
43 | "javascript"
44 | "typescript"
45 | "python"
46 | "go"
47 | "c"
48 | "c++"
49 | "java"
50 | "c#"
51 | "php"
52 | "ruby"
53 )
54}
55
56pub fn build_complexity_report(
58 root: &Path,
59 files: &[PathBuf],
60 export: &ExportData,
61 limits: &AnalysisLimits,
62 detail_functions: bool,
63) -> Result<ComplexityReport> {
64 let mut row_map: BTreeMap<String, &FileRow> = BTreeMap::new();
65 for row in export.rows.iter().filter(|r| r.kind == FileKind::Parent) {
66 row_map.insert(normalize_path(&row.path, root), row);
67 }
68
69 let mut file_complexities: Vec<FileComplexity> = Vec::new();
70 let mut total_bytes = 0u64;
71 let max_total = limits.max_bytes;
72 let per_file_limit = limits.max_file_bytes.unwrap_or(DEFAULT_MAX_FILE_BYTES) as usize;
73
74 for rel in files {
75 if max_total.is_some_and(|limit| total_bytes >= limit) {
76 break;
77 }
78 let rel_str = rel.to_string_lossy().replace('\\', "/");
79 let row = match row_map.get(&rel_str) {
80 Some(r) => *r,
81 None => continue,
82 };
83 if !is_complexity_lang(&row.lang) {
84 continue;
85 }
86
87 let path = root.join(rel);
88 let bytes = match tokmd_content::read_head(&path, per_file_limit) {
89 Ok(b) => b,
90 Err(_) => continue,
91 };
92 total_bytes += bytes.len() as u64;
93
94 if !tokmd_content::is_text_like(&bytes) {
95 continue;
96 }
97
98 let text = String::from_utf8_lossy(&bytes);
99 let lang_mapped = map_language_for_complexity(&row.lang);
100 let (function_count, max_function_length) = count_functions(&row.lang, &text);
101 let cyclomatic = estimate_cyclomatic(&row.lang, &text);
102
103 let cognitive_result =
105 tokmd_content::complexity::estimate_cognitive_complexity(&text, lang_mapped);
106 let nesting_result = tokmd_content::complexity::analyze_nesting_depth(&text, lang_mapped);
107
108 let cognitive_complexity = if cognitive_result.function_count > 0 {
109 Some(cognitive_result.total)
110 } else {
111 None
112 };
113 let max_nesting = if nesting_result.max_depth > 0 {
114 Some(nesting_result.max_depth)
115 } else {
116 None
117 };
118
119 let risk_level = classify_risk_extended(
120 function_count,
121 max_function_length,
122 cyclomatic,
123 cognitive_complexity,
124 max_nesting,
125 );
126
127 let functions = if detail_functions {
128 Some(extract_function_details(&row.lang, &text))
129 } else {
130 None
131 };
132
133 file_complexities.push(FileComplexity {
134 path: rel_str,
135 module: row.module.clone(),
136 function_count,
137 max_function_length,
138 cyclomatic_complexity: cyclomatic,
139 cognitive_complexity,
140 max_nesting,
141 risk_level,
142 functions,
143 });
144 }
145
146 file_complexities.sort_by(|a, b| {
148 b.cyclomatic_complexity
149 .cmp(&a.cyclomatic_complexity)
150 .then_with(|| a.path.cmp(&b.path))
151 });
152
153 let total_functions: usize = file_complexities.iter().map(|f| f.function_count).sum();
155 let file_count = file_complexities.len();
156
157 let avg_function_length = if total_functions == 0 {
158 0.0
159 } else {
160 let total_max_len: usize = file_complexities
161 .iter()
162 .map(|f| f.max_function_length)
163 .sum();
164 round_f64(total_max_len as f64 / file_count as f64, 2)
165 };
166
167 let max_function_length = file_complexities
168 .iter()
169 .map(|f| f.max_function_length)
170 .max()
171 .unwrap_or(0);
172
173 let avg_cyclomatic = if file_count == 0 {
174 0.0
175 } else {
176 let total_cyclo: usize = file_complexities
177 .iter()
178 .map(|f| f.cyclomatic_complexity)
179 .sum();
180 round_f64(total_cyclo as f64 / file_count as f64, 2)
181 };
182
183 let max_cyclomatic = file_complexities
184 .iter()
185 .map(|f| f.cyclomatic_complexity)
186 .max()
187 .unwrap_or(0);
188
189 let cognitive_values: Vec<usize> = file_complexities
191 .iter()
192 .filter_map(|f| f.cognitive_complexity)
193 .collect();
194 let (avg_cognitive, max_cognitive) = if cognitive_values.is_empty() {
195 (None, None)
196 } else {
197 let total: usize = cognitive_values.iter().sum();
198 let max = cognitive_values.iter().copied().max().unwrap_or(0);
199 (
200 Some(round_f64(total as f64 / cognitive_values.len() as f64, 2)),
201 Some(max),
202 )
203 };
204
205 let nesting_values: Vec<usize> = file_complexities
207 .iter()
208 .filter_map(|f| f.max_nesting)
209 .collect();
210 let (avg_nesting_depth, max_nesting_depth) = if nesting_values.is_empty() {
211 (None, None)
212 } else {
213 let total: usize = nesting_values.iter().sum();
214 let max = nesting_values.iter().copied().max().unwrap_or(0);
215 (
216 Some(round_f64(total as f64 / nesting_values.len() as f64, 2)),
217 Some(max),
218 )
219 };
220
221 let high_risk_files = file_complexities
222 .iter()
223 .filter(|f| {
224 matches!(
225 f.risk_level,
226 ComplexityRisk::High | ComplexityRisk::Critical
227 )
228 })
229 .count();
230
231 let histogram = generate_complexity_histogram(&file_complexities, 5);
233
234 let maintainability_index = if file_count == 0 {
236 None
237 } else {
238 average_parent_loc(export)
239 .and_then(|avg_loc| compute_maintainability_index(avg_cyclomatic, avg_loc, None))
240 };
241 let technical_debt = compute_technical_debt_ratio(export, &file_complexities);
242
243 file_complexities.truncate(MAX_COMPLEXITY_FILES);
245
246 Ok(ComplexityReport {
247 total_functions,
248 avg_function_length,
249 max_function_length,
250 avg_cyclomatic,
251 max_cyclomatic,
252 avg_cognitive,
253 max_cognitive,
254 avg_nesting_depth,
255 max_nesting_depth,
256 high_risk_files,
257 histogram: Some(histogram),
258 halstead: None, maintainability_index,
260 technical_debt,
261 files: file_complexities,
262 })
263}
264
265pub fn generate_complexity_histogram(
279 files: &[FileComplexity],
280 bucket_size: u32,
281) -> ComplexityHistogram {
282 let num_buckets = 7;
284 let mut counts = vec![0u32; num_buckets];
285
286 for file in files {
287 let complexity = file.cyclomatic_complexity as u32;
288 let bucket = (complexity / bucket_size).min((num_buckets - 1) as u32) as usize;
289 counts[bucket] += 1;
290 }
291
292 ComplexityHistogram {
293 buckets: (0..num_buckets).map(|i| (i as u32) * bucket_size).collect(),
294 counts,
295 total: files.len() as u32,
296 }
297}
298
299fn count_functions(lang: &str, text: &str) -> (usize, usize) {
301 let lines: Vec<&str> = text.lines().collect();
302 match lang.to_lowercase().as_str() {
303 "rust" => count_rust_functions(&lines),
304 "javascript" | "typescript" => count_js_functions(&lines),
305 "python" => count_python_functions(&lines),
306 "go" => count_go_functions(&lines),
307 "c" | "c++" | "java" | "c#" | "php" => count_c_style_functions(&lines),
308 "ruby" => count_ruby_functions(&lines),
309 _ => (0, 0),
310 }
311}
312
313fn is_rust_fn_start(trimmed: &str) -> bool {
318 let Some(fn_pos) = trimmed.find("fn ") else {
320 return false;
321 };
322
323 let prefix = trimmed[..fn_pos].trim();
325 if prefix.is_empty() {
326 return true; }
328
329 let mut rest = prefix;
331 while !rest.is_empty() {
332 rest = rest.trim_start();
333 if rest.is_empty() {
334 break;
335 }
336 if rest.starts_with("pub(") {
337 if let Some(close) = rest.find(')') {
339 rest = &rest[close + 1..];
340 } else {
341 return false; }
343 } else if let Some(r) = rest.strip_prefix("pub") {
344 rest = r;
345 } else if let Some(r) = rest.strip_prefix("async") {
346 rest = r;
347 } else if let Some(r) = rest.strip_prefix("unsafe") {
348 rest = r;
349 } else if let Some(r) = rest.strip_prefix("const") {
350 rest = r;
351 } else if rest.starts_with("extern") {
352 rest = rest["extern".len()..].trim_start();
354 if rest.starts_with('"') {
355 if let Some(close) = rest[1..].find('"') {
356 rest = &rest[close + 2..];
357 } else {
358 return false; }
360 }
361 } else {
362 return false; }
364 }
365
366 true
367}
368
369fn count_rust_functions(lines: &[&str]) -> (usize, usize) {
370 let mut count = 0;
371 let mut max_len = 0;
372 let mut in_fn = false;
373 let mut fn_start = 0;
374 let mut brace_depth: i32 = 0;
375 let mut in_string = false;
376 let mut in_block_comment = false;
377
378 for (i, line) in lines.iter().enumerate() {
379 let trimmed = line.trim();
380
381 if !in_fn && is_rust_fn_start(trimmed) {
383 count += 1;
384 in_fn = true;
385 fn_start = i;
386 brace_depth = 0;
387 }
388
389 if in_fn {
390 let chars: Vec<char> = line.chars().collect();
391 let mut j = 0;
392 while j < chars.len() {
393 let c = chars[j];
394 let next = chars.get(j + 1).copied();
395
396 if in_block_comment {
397 if c == '*' && next == Some('/') {
398 in_block_comment = false;
399 j += 2;
400 continue;
401 }
402 j += 1;
403 continue;
404 }
405
406 if c == '/' && next == Some('/') {
407 break; }
409
410 if c == '/' && next == Some('*') {
411 in_block_comment = true;
412 j += 2;
413 continue;
414 }
415
416 if c == '"' && (j == 0 || chars[j - 1] != '\\') {
417 in_string = !in_string;
418 j += 1;
419 continue;
420 }
421
422 if !in_string && !in_block_comment {
423 if c == '{' {
424 brace_depth += 1;
425 } else if c == '}' {
426 brace_depth = brace_depth.saturating_sub(1);
427 if brace_depth == 0 {
428 let fn_len = i - fn_start + 1;
429 max_len = max_len.max(fn_len);
430 in_fn = false;
431 break;
432 }
433 }
434 }
435 j += 1;
436 }
437 }
438 }
439
440 (count, max_len)
441}
442
443fn count_js_functions(lines: &[&str]) -> (usize, usize) {
444 let mut count = 0;
445 let mut max_len = 0;
446 let mut in_fn = false;
447 let mut fn_start = 0;
448 let mut brace_depth = 0;
449
450 for (i, line) in lines.iter().enumerate() {
451 let trimmed = line.trim();
452
453 let is_fn_start = trimmed.starts_with("function ")
455 || trimmed.starts_with("async function ")
456 || trimmed.contains("=> {")
457 || (trimmed.contains("(")
458 && trimmed.contains(") {")
459 && !trimmed.starts_with("if ")
460 && !trimmed.starts_with("while ")
461 && !trimmed.starts_with("for ")
462 && !trimmed.starts_with("switch "));
463
464 if !in_fn && is_fn_start {
465 count += 1;
466 in_fn = true;
467 fn_start = i;
468 brace_depth = 0;
469 }
470
471 if in_fn {
472 brace_depth += line.chars().filter(|&c| c == '{').count();
473 brace_depth = brace_depth.saturating_sub(line.chars().filter(|&c| c == '}').count());
474
475 if brace_depth == 0 && line.contains('}') {
476 let fn_len = i - fn_start + 1;
477 max_len = max_len.max(fn_len);
478 in_fn = false;
479 }
480 }
481 }
482
483 (count, max_len)
484}
485
486fn count_python_functions(lines: &[&str]) -> (usize, usize) {
487 let mut count = 0;
488 let mut max_len = 0;
489 let mut fn_start = 0;
490 let mut fn_indent = 0;
491 let mut in_fn = false;
492
493 for (i, line) in lines.iter().enumerate() {
494 let trimmed = line.trim();
495
496 if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
497 if in_fn {
498 let fn_len = i - fn_start;
500 max_len = max_len.max(fn_len);
501 }
502 count += 1;
503 in_fn = true;
504 fn_start = i;
505 fn_indent = line.len() - line.trim_start().len();
506 } else if in_fn && !trimmed.is_empty() && !trimmed.starts_with('#') {
507 let current_indent = line.len() - line.trim_start().len();
508 if current_indent <= fn_indent
509 && !trimmed.starts_with("def ")
510 && !trimmed.starts_with("async def ")
511 {
512 let fn_len = i - fn_start;
513 max_len = max_len.max(fn_len);
514 in_fn = false;
515 }
516 }
517 }
518
519 if in_fn {
520 let fn_len = lines.len() - fn_start;
521 max_len = max_len.max(fn_len);
522 }
523
524 (count, max_len)
525}
526
527fn count_go_functions(lines: &[&str]) -> (usize, usize) {
528 let mut count = 0;
529 let mut max_len = 0;
530 let mut in_fn = false;
531 let mut fn_start = 0;
532 let mut brace_depth = 0;
533
534 for (i, line) in lines.iter().enumerate() {
535 let trimmed = line.trim();
536
537 if !in_fn && trimmed.starts_with("func ") {
538 count += 1;
539 in_fn = true;
540 fn_start = i;
541 brace_depth = 0;
542 }
543
544 if in_fn {
545 brace_depth += line.chars().filter(|&c| c == '{').count();
546 brace_depth = brace_depth.saturating_sub(line.chars().filter(|&c| c == '}').count());
547
548 if brace_depth == 0 && line.contains('}') {
549 let fn_len = i - fn_start + 1;
550 max_len = max_len.max(fn_len);
551 in_fn = false;
552 }
553 }
554 }
555
556 (count, max_len)
557}
558
559fn count_c_style_functions(lines: &[&str]) -> (usize, usize) {
560 let mut count = 0;
561 let mut max_len = 0;
562 let mut in_fn = false;
563 let mut fn_start = 0;
564 let mut brace_depth = 0;
565
566 for (i, line) in lines.iter().enumerate() {
567 let trimmed = line.trim();
568
569 let looks_like_fn = trimmed.ends_with(") {")
571 || (trimmed.ends_with(')') && i + 1 < lines.len() && lines[i + 1].trim() == "{");
572
573 let is_control = trimmed.starts_with("if ")
575 || trimmed.starts_with("if(")
576 || trimmed.starts_with("while ")
577 || trimmed.starts_with("while(")
578 || trimmed.starts_with("for ")
579 || trimmed.starts_with("for(")
580 || trimmed.starts_with("switch ")
581 || trimmed.starts_with("switch(");
582
583 if !in_fn && looks_like_fn && !is_control {
584 count += 1;
585 in_fn = true;
586 fn_start = i;
587 brace_depth = 0;
588 }
589
590 if in_fn {
591 brace_depth += line.chars().filter(|&c| c == '{').count();
592 brace_depth = brace_depth.saturating_sub(line.chars().filter(|&c| c == '}').count());
593
594 if brace_depth == 0 && line.contains('}') {
595 let fn_len = i - fn_start + 1;
596 max_len = max_len.max(fn_len);
597 in_fn = false;
598 }
599 }
600 }
601
602 (count, max_len)
603}
604
605fn count_ruby_functions(lines: &[&str]) -> (usize, usize) {
606 let mut count = 0;
607 let mut max_len = 0;
608 let mut fn_start = 0;
609 let mut in_fn = false;
610 let mut depth = 0;
611
612 for (i, line) in lines.iter().enumerate() {
613 let trimmed = line.trim();
614
615 if trimmed.starts_with("def ") {
616 if !in_fn {
617 count += 1;
618 in_fn = true;
619 fn_start = i;
620 depth = 1;
621 } else {
622 depth += 1;
623 }
624 } else if in_fn {
625 if trimmed.starts_with("do")
627 || trimmed.starts_with("class ")
628 || trimmed.starts_with("module ")
629 || trimmed.starts_with("begin")
630 || trimmed.starts_with("if ")
631 || trimmed.starts_with("unless ")
632 || trimmed.starts_with("case ")
633 || trimmed.starts_with("while ")
634 || trimmed.starts_with("until ")
635 || trimmed.starts_with("for ")
636 {
637 depth += 1;
638 }
639 if trimmed == "end" || trimmed.starts_with("end ") {
640 depth -= 1;
641 if depth == 0 {
642 let fn_len = i - fn_start + 1;
643 max_len = max_len.max(fn_len);
644 in_fn = false;
645 }
646 }
647 }
648 }
649
650 (count, max_len)
651}
652
653fn estimate_cyclomatic(lang: &str, text: &str) -> usize {
655 let mut complexity = 1usize;
657
658 let keywords: &[&str] = match lang.to_lowercase().as_str() {
659 "rust" => &["if ", "match ", "while ", "for ", "loop ", "?", "&&", "||"],
660 "javascript" | "typescript" => {
661 &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
662 }
663 "python" => &["if ", "elif ", "while ", "for ", "except ", " and ", " or "],
664 "go" => &["if ", "case ", "for ", "select ", "&&", "||"],
665 "c" | "c++" | "java" | "c#" | "php" => {
666 &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
667 }
668 "ruby" => &[
669 "if ", "elsif ", "unless ", "while ", "until ", "for ", "when ", "rescue ", " and ",
670 " or ",
671 ],
672 _ => &[],
673 };
674
675 let lower = text.to_lowercase();
676 for keyword in keywords {
677 complexity += lower.matches(keyword).count();
678 }
679
680 complexity
681}
682
683fn classify_risk_extended(
685 function_count: usize,
686 max_function_length: usize,
687 cyclomatic: usize,
688 cognitive: Option<usize>,
689 max_nesting: Option<usize>,
690) -> ComplexityRisk {
691 let mut score = 0;
693
694 if function_count > 50 {
696 score += 2;
697 } else if function_count > 20 {
698 score += 1;
699 }
700
701 if max_function_length > 100 {
703 score += 3;
704 } else if max_function_length > 50 {
705 score += 2;
706 } else if max_function_length > 25 {
707 score += 1;
708 }
709
710 if cyclomatic > 50 {
712 score += 3;
713 } else if cyclomatic > 20 {
714 score += 2;
715 } else if cyclomatic > 10 {
716 score += 1;
717 }
718
719 if let Some(cog) = cognitive {
721 if cog > 100 {
722 score += 3;
723 } else if cog > 50 {
724 score += 2;
725 } else if cog > 25 {
726 score += 1;
727 }
728 }
729
730 if let Some(nesting) = max_nesting {
732 if nesting > 8 {
733 score += 3;
734 } else if nesting > 5 {
735 score += 2;
736 } else if nesting > 4 {
737 score += 1;
738 }
739 }
740
741 match score {
742 0..=1 => ComplexityRisk::Low,
743 2..=4 => ComplexityRisk::Moderate,
744 5..=7 => ComplexityRisk::High,
745 _ => ComplexityRisk::Critical,
746 }
747}
748
749fn average_parent_loc(export: &ExportData) -> Option<f64> {
750 let total_code: usize = export
751 .rows
752 .iter()
753 .filter(|r| r.kind == FileKind::Parent)
754 .map(|r| r.code)
755 .sum();
756 let parent_count: usize = export
757 .rows
758 .iter()
759 .filter(|r| r.kind == FileKind::Parent)
760 .count();
761
762 if parent_count == 0 {
763 return None;
764 }
765
766 let avg_loc = total_code as f64 / parent_count as f64;
767 if avg_loc <= 0.0 {
768 return None;
769 }
770 Some(avg_loc)
771}
772
773fn compute_technical_debt_ratio(
777 export: &ExportData,
778 file_complexities: &[FileComplexity],
779) -> Option<TechnicalDebtRatio> {
780 if file_complexities.is_empty() {
781 return None;
782 }
783
784 let total_code: usize = export
785 .rows
786 .iter()
787 .filter(|r| r.kind == FileKind::Parent)
788 .map(|r| r.code)
789 .sum();
790 if total_code == 0 {
791 return None;
792 }
793
794 let complexity_points: usize = file_complexities
795 .iter()
796 .map(|f| f.cyclomatic_complexity + f.cognitive_complexity.unwrap_or(0))
797 .sum();
798
799 let code_kloc = total_code as f64 / 1000.0;
800 let ratio = round_f64(complexity_points as f64 / code_kloc, 2);
801 let level = if ratio < TECHNICAL_DEBT_LOW_THRESHOLD {
802 TechnicalDebtLevel::Low
803 } else if ratio < TECHNICAL_DEBT_MODERATE_THRESHOLD {
804 TechnicalDebtLevel::Moderate
805 } else if ratio < TECHNICAL_DEBT_HIGH_THRESHOLD {
806 TechnicalDebtLevel::High
807 } else {
808 TechnicalDebtLevel::Critical
809 };
810
811 Some(TechnicalDebtRatio {
812 ratio,
813 complexity_points,
814 code_kloc: round_f64(code_kloc, 4),
815 level,
816 })
817}
818
819fn extract_function_details(lang: &str, text: &str) -> Vec<FunctionComplexityDetail> {
821 let lines: Vec<&str> = text.lines().collect();
822 let mapped_lang = map_language_for_complexity(lang);
823
824 let fn_spans: Vec<(usize, usize, String)> = match lang.to_lowercase().as_str() {
825 "rust" => detect_fn_spans_rust(&lines),
826 "javascript" | "typescript" => detect_fn_spans_js(&lines),
827 "python" => detect_fn_spans_python(&lines),
828 "go" => detect_fn_spans_go(&lines),
829 "c" | "c++" | "java" | "c#" | "php" => detect_fn_spans_c_style(&lines),
830 _ => Vec::new(),
831 };
832
833 fn_spans
834 .into_iter()
835 .map(|(start, end, name)| {
836 let length = end.saturating_sub(start) + 1;
837 let fn_text = lines[start..=end.min(lines.len() - 1)].join("\n");
838 let cyclomatic = estimate_cyclomatic_inline(mapped_lang, &fn_text);
839
840 let cognitive_result =
841 tokmd_content::complexity::estimate_cognitive_complexity(&fn_text, mapped_lang);
842 let cognitive = Some(cognitive_result.total);
843
844 let nesting_result =
845 tokmd_content::complexity::analyze_nesting_depth(&fn_text, mapped_lang);
846 let max_nesting = if nesting_result.max_depth > 0 {
847 Some(nesting_result.max_depth)
848 } else {
849 None
850 };
851
852 let param_count = count_params(lines.get(start).unwrap_or(&""));
853
854 FunctionComplexityDetail {
855 name,
856 line_start: start + 1,
857 line_end: end + 1,
858 length,
859 cyclomatic,
860 cognitive,
861 max_nesting,
862 param_count: if param_count > 0 {
863 Some(param_count)
864 } else {
865 None
866 },
867 }
868 })
869 .collect()
870}
871
872fn detect_fn_spans_rust(lines: &[&str]) -> Vec<(usize, usize, String)> {
874 let mut spans = Vec::new();
875 let mut i = 0;
876 while i < lines.len() {
877 let trimmed = lines[i].trim();
878 if is_rust_fn_start(trimmed) {
879 let name = extract_rust_fn_name(trimmed);
880 let start = i;
881 if let Some(end) = find_brace_end_at(lines, i) {
882 spans.push((start, end, name));
883 i = end + 1;
884 } else {
885 i += 1;
887 }
888 } else {
889 i += 1;
890 }
891 }
892 spans
893}
894
895fn detect_fn_spans_js(lines: &[&str]) -> Vec<(usize, usize, String)> {
897 let mut spans = Vec::new();
898 let mut i = 0;
899 while i < lines.len() {
900 let trimmed = lines[i].trim();
901 let is_fn = trimmed.starts_with("function ")
902 || trimmed.starts_with("async function ")
903 || trimmed.starts_with("export function ")
904 || trimmed.starts_with("export async function ")
905 || trimmed.contains("=> {");
906 if is_fn && !trimmed.starts_with("//") {
907 let name = extract_js_fn_name(trimmed);
908 let start = i;
909 if let Some(end) = find_brace_end_at(lines, i) {
910 spans.push((start, end, name));
911 i = end + 1;
912 } else {
913 i += 1;
914 }
915 } else {
916 i += 1;
917 }
918 }
919 spans
920}
921
922fn detect_fn_spans_python(lines: &[&str]) -> Vec<(usize, usize, String)> {
924 let mut spans = Vec::new();
925 let mut i = 0;
926 while i < lines.len() {
927 let trimmed = lines[i].trim();
928 if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
929 let name = extract_python_fn_name(trimmed);
930 let base_indent = lines[i].len() - lines[i].trim_start().len();
931
932 let mut start = i;
934 {
935 let mut k = i;
936 while k > 0 {
937 let prev = lines[k - 1].trim();
938 if prev.is_empty() {
939 k -= 1;
940 continue;
941 }
942 let prev_indent = lines[k - 1].len() - lines[k - 1].trim_start().len();
943 if prev_indent == base_indent && prev.starts_with('@') {
944 start = k - 1;
945 k -= 1;
946 } else {
947 break;
948 }
949 }
950 }
951 let mut end = i;
952 let mut j = i + 1;
953 while j < lines.len() {
954 let lt = lines[j].trim();
955 if lt.is_empty() || lt.starts_with('#') {
956 j += 1;
957 continue;
958 }
959 let indent = lines[j].len() - lines[j].trim_start().len();
960 if indent <= base_indent {
961 break;
962 }
963 end = j;
964 j += 1;
965 }
966 spans.push((start, end, name));
967 i = end + 1;
968 } else {
969 i += 1;
970 }
971 }
972 spans
973}
974
975fn detect_fn_spans_go(lines: &[&str]) -> Vec<(usize, usize, String)> {
977 let mut spans = Vec::new();
978 let mut i = 0;
979 while i < lines.len() {
980 let trimmed = lines[i].trim();
981 if trimmed.starts_with("func ") {
982 let name = extract_go_fn_name(trimmed);
983 let start = i;
984 if let Some(end) = find_brace_end_at(lines, i) {
985 spans.push((start, end, name));
986 i = end + 1;
987 } else {
988 i += 1;
989 }
990 } else {
991 i += 1;
992 }
993 }
994 spans
995}
996
997fn detect_fn_spans_c_style(lines: &[&str]) -> Vec<(usize, usize, String)> {
999 let mut spans = Vec::new();
1000 let mut i = 0;
1001 while i < lines.len() {
1002 let trimmed = lines[i].trim();
1003 let looks_like_fn = (trimmed.ends_with(") {") || trimmed.ends_with("){"))
1004 && !trimmed.starts_with("if ")
1005 && !trimmed.starts_with("if(")
1006 && !trimmed.starts_with("while ")
1007 && !trimmed.starts_with("for ")
1008 && !trimmed.starts_with("switch ")
1009 && !trimmed.starts_with("//")
1010 && !trimmed.starts_with('#');
1011 if looks_like_fn {
1012 let name = extract_c_fn_name(trimmed);
1013 let start = i;
1014 if let Some(end) = find_brace_end_at(lines, i) {
1015 spans.push((start, end, name));
1016 i = end + 1;
1017 } else {
1018 i += 1;
1019 }
1020 } else {
1021 i += 1;
1022 }
1023 }
1024 spans
1025}
1026
1027fn find_brace_end_at(lines: &[&str], start_line: usize) -> Option<usize> {
1032 let mut depth: usize = 0;
1033 let mut found_open = false;
1034 for (i, line) in lines.iter().enumerate().skip(start_line) {
1035 for ch in line.chars() {
1036 if ch == '{' {
1037 depth += 1;
1038 found_open = true;
1039 } else if ch == '}' {
1040 depth = depth.saturating_sub(1);
1041 if found_open && depth == 0 {
1042 return Some(i);
1043 }
1044 }
1045 }
1046 }
1047 None
1049}
1050
1051fn extract_rust_fn_name(line: &str) -> String {
1053 if let Some(idx) = line.find("fn ") {
1054 let after = &line[idx + 3..];
1055 let name: String = after
1056 .chars()
1057 .take_while(|c| c.is_alphanumeric() || *c == '_')
1058 .collect();
1059 if !name.is_empty() {
1060 return name;
1061 }
1062 }
1063 "<unknown>".to_string()
1064}
1065
1066fn extract_js_fn_name(line: &str) -> String {
1067 if let Some(idx) = line.find("function ") {
1068 let after = &line[idx + 9..];
1069 let name: String = after
1070 .chars()
1071 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
1072 .collect();
1073 if !name.is_empty() {
1074 return name;
1075 }
1076 }
1077 if let Some(paren_idx) = line.find('(') {
1078 let before = line[..paren_idx].trim();
1079 let name: String = before
1080 .chars()
1081 .rev()
1082 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
1083 .collect::<Vec<_>>()
1084 .into_iter()
1085 .rev()
1086 .collect();
1087 if !name.is_empty() {
1088 return name;
1089 }
1090 }
1091 "<anonymous>".to_string()
1092}
1093
1094fn extract_python_fn_name(line: &str) -> String {
1095 let keyword = if line.contains("async def ") {
1096 "async def "
1097 } else {
1098 "def "
1099 };
1100 if let Some(idx) = line.find(keyword) {
1101 let after = &line[idx + keyword.len()..];
1102 let name: String = after
1103 .chars()
1104 .take_while(|c| c.is_alphanumeric() || *c == '_')
1105 .collect();
1106 if !name.is_empty() {
1107 return name;
1108 }
1109 }
1110 "<unknown>".to_string()
1111}
1112
1113fn extract_go_fn_name(line: &str) -> String {
1114 if let Some(idx) = line.find("func ") {
1115 let after = &line[idx + 5..];
1116 let after = if after.starts_with('(') {
1117 if let Some(close) = after.find(')') {
1118 after[close + 1..].trim_start()
1119 } else {
1120 after
1121 }
1122 } else {
1123 after
1124 };
1125 let name: String = after
1126 .chars()
1127 .take_while(|c| c.is_alphanumeric() || *c == '_')
1128 .collect();
1129 if !name.is_empty() {
1130 return name;
1131 }
1132 }
1133 "<unknown>".to_string()
1134}
1135
1136fn extract_c_fn_name(line: &str) -> String {
1137 if let Some(paren_idx) = line.find('(') {
1138 let before = line[..paren_idx].trim();
1139 let name: String = before
1140 .chars()
1141 .rev()
1142 .take_while(|c| c.is_alphanumeric() || *c == '_')
1143 .collect::<Vec<_>>()
1144 .into_iter()
1145 .rev()
1146 .collect();
1147 if !name.is_empty() {
1148 return name;
1149 }
1150 }
1151 "<unknown>".to_string()
1152}
1153
1154fn count_params(line: &str) -> usize {
1156 if let Some(open) = line.find('(')
1157 && let Some(close) = line.find(')')
1158 {
1159 let params = line[open + 1..close].trim();
1160 if params.is_empty() {
1161 return 0;
1162 }
1163 return params.split(',').count();
1164 }
1165 0
1166}
1167
1168fn estimate_cyclomatic_inline(lang: &str, text: &str) -> usize {
1170 let mut complexity = 1usize;
1171 let keywords: &[&str] = match lang {
1172 "rust" => &["if ", "match ", "while ", "for ", "loop ", "?", "&&", "||"],
1173 "javascript" | "typescript" => {
1174 &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
1175 }
1176 "python" => &["if ", "elif ", "while ", "for ", "except ", " and ", " or "],
1177 "go" => &["if ", "case ", "for ", "select ", "&&", "||"],
1178 "c" | "c++" | "java" | "c#" | "php" => {
1179 &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
1180 }
1181 _ => &[],
1182 };
1183 let lower = text.to_lowercase();
1184 for keyword in keywords {
1185 complexity += lower.matches(keyword).count();
1186 }
1187 complexity
1188}
1189
1190fn round_f64(val: f64, decimals: u32) -> f64 {
1191 let factor = 10f64.powi(decimals as i32);
1192 (val * factor).round() / factor
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::*;
1198
1199 #[test]
1200 fn test_count_rust_functions() {
1201 let code = r#"
1202fn simple() {
1203 println!("hello");
1204}
1205
1206pub fn public_fn() {
1207 let x = 1;
1208 let y = 2;
1209}
1210
1211pub async fn async_fn() {
1212 todo!()
1213}
1214"#;
1215 let lines: Vec<&str> = code.lines().collect();
1216 let (count, _max_len) = count_rust_functions(&lines);
1217 assert_eq!(count, 3);
1218 }
1219
1220 #[test]
1221 fn test_count_python_functions() {
1222 let code = r#"
1223def foo():
1224 pass
1225
1226async def bar():
1227 await something()
1228
1229def baz():
1230 x = 1
1231 y = 2
1232 return x + y
1233"#;
1234 let lines: Vec<&str> = code.lines().collect();
1235 let (count, _max_len) = count_python_functions(&lines);
1236 assert_eq!(count, 3);
1237 }
1238
1239 #[test]
1240 fn test_estimate_cyclomatic_rust() {
1241 let code = r#"
1242fn complex(x: i32) -> i32 {
1243 if x > 0 {
1244 if x > 10 {
1245 x * 2
1246 } else {
1247 x + 1
1248 }
1249 } else {
1250 match x {
1251 -1 => 0,
1252 _ => x.abs(),
1253 }
1254 }
1255}
1256"#;
1257 let cyclo = estimate_cyclomatic("rust", code);
1258 assert_eq!(cyclo, 4);
1260 }
1261
1262 #[test]
1263 fn test_estimate_cyclomatic_rust_no_else_if_double_count() {
1264 let code = r#"
1266fn branchy(x: i32) -> i32 {
1267 if x > 0 {
1268 1
1269 } else if x < 0 {
1270 -1
1271 } else if x == 0 {
1272 0
1273 } else {
1274 42
1275 }
1276}
1277"#;
1278 let cyclo = estimate_cyclomatic("rust", code);
1279 assert_eq!(cyclo, 4);
1281 }
1282
1283 #[test]
1284 fn test_estimate_cyclomatic_js_no_switch_double_count() {
1285 let code = r#"
1287function classify(x) {
1288 switch (x) {
1289 case 1: return "one";
1290 case 2: return "two";
1291 case 3: return "three";
1292 default: return "other";
1293 }
1294}
1295"#;
1296 let cyclo = estimate_cyclomatic("javascript", code);
1297 assert_eq!(cyclo, 4);
1299 }
1300
1301 #[test]
1302 fn test_classify_risk() {
1303 assert_eq!(
1304 classify_risk_extended(5, 10, 5, None, None),
1305 ComplexityRisk::Low
1306 );
1307 assert_eq!(
1308 classify_risk_extended(25, 30, 15, None, None),
1309 ComplexityRisk::Moderate
1310 );
1311 assert_eq!(
1312 classify_risk_extended(30, 60, 25, None, None),
1313 ComplexityRisk::High
1314 );
1315 assert_eq!(
1316 classify_risk_extended(60, 120, 60, None, None),
1317 ComplexityRisk::Critical
1318 );
1319 }
1320
1321 #[test]
1322 fn test_classify_risk_with_cognitive() {
1323 assert_eq!(
1325 classify_risk_extended(5, 10, 5, Some(10), Some(2)),
1326 ComplexityRisk::Low
1327 );
1328 assert!(matches!(
1330 classify_risk_extended(5, 10, 5, Some(60), Some(6)),
1331 ComplexityRisk::Moderate | ComplexityRisk::High
1332 ));
1333 assert!(matches!(
1335 classify_risk_extended(5, 10, 5, Some(10), Some(9)),
1336 ComplexityRisk::Moderate | ComplexityRisk::High
1337 ));
1338 }
1339
1340 #[test]
1341 fn test_is_complexity_lang() {
1342 assert!(is_complexity_lang("Rust"));
1343 assert!(is_complexity_lang("javascript"));
1344 assert!(is_complexity_lang("Python"));
1345 assert!(!is_complexity_lang("Markdown"));
1346 assert!(!is_complexity_lang("JSON"));
1347 }
1348
1349 #[test]
1350 fn test_is_rust_fn_start_extended() {
1351 assert!(is_rust_fn_start("fn foo()"));
1353 assert!(is_rust_fn_start("pub fn foo()"));
1354 assert!(is_rust_fn_start("pub(crate) fn foo()"));
1355 assert!(is_rust_fn_start("pub(super) fn foo()"));
1356 assert!(is_rust_fn_start("async fn foo()"));
1357 assert!(is_rust_fn_start("pub async fn foo()"));
1358 assert!(is_rust_fn_start("unsafe fn foo()"));
1359 assert!(is_rust_fn_start("const fn foo()"));
1360
1361 assert!(is_rust_fn_start("pub(in crate::foo) fn bar()"));
1363 assert!(is_rust_fn_start("pub(in crate::foo::bar) fn baz()"));
1364
1365 assert!(is_rust_fn_start(r#"extern "C" fn callback()"#));
1367 assert!(is_rust_fn_start(r#"pub extern "C" fn callback()"#));
1368 assert!(is_rust_fn_start(r#"pub unsafe extern "C" fn callback()"#));
1369
1370 assert!(is_rust_fn_start("pub(crate) unsafe async fn baz()"));
1372 assert!(is_rust_fn_start("pub(super) const fn helper()"));
1373
1374 assert!(!is_rust_fn_start("let fn_name = 5;"));
1376 assert!(!is_rust_fn_start("// fn foo()"));
1377 assert!(!is_rust_fn_start("struct Foo {"));
1378 }
1379
1380 #[test]
1381 fn test_detect_fn_rust_qualifiers() {
1382 let code = r#"
1383pub(crate) async fn crate_async() {
1384 todo!()
1385}
1386
1387pub(super) async fn super_async() {
1388 todo!()
1389}
1390
1391pub(crate) unsafe fn crate_unsafe() {
1392 todo!()
1393}
1394
1395pub unsafe fn public_unsafe() {
1396 todo!()
1397}
1398
1399pub(crate) const fn crate_const() -> u32 {
1400 42
1401}
1402
1403pub const fn public_const() -> u32 {
1404 0
1405}
1406"#;
1407 let lines: Vec<&str> = code.lines().collect();
1408 let spans = detect_fn_spans_rust(&lines);
1409 let names: Vec<&str> = spans.iter().map(|(_, _, n)| n.as_str()).collect();
1410 assert_eq!(
1411 names,
1412 vec![
1413 "crate_async",
1414 "super_async",
1415 "crate_unsafe",
1416 "public_unsafe",
1417 "crate_const",
1418 "public_const",
1419 ]
1420 );
1421
1422 let (count, _) = count_rust_functions(&lines);
1424 assert_eq!(count, 6);
1425 }
1426
1427 #[test]
1428 fn test_detect_fn_python_decorators() {
1429 let code = r#"
1430@staticmethod
1431def plain_static():
1432 pass
1433
1434@app.route("/")
1435@login_required
1436def index():
1437 return "hello"
1438
1439def no_decorator():
1440 pass
1441"#;
1442 let lines: Vec<&str> = code.lines().collect();
1443 let spans = detect_fn_spans_python(&lines);
1444 assert_eq!(spans.len(), 3);
1445
1446 let (start, _end, ref name) = spans[0];
1448 assert_eq!(name, "plain_static");
1449 assert!(lines[start].trim().starts_with('@'));
1451
1452 let (start2, _end2, ref name2) = spans[1];
1454 assert_eq!(name2, "index");
1455 assert!(lines[start2].trim().starts_with('@'));
1456
1457 let (start3, _end3, ref name3) = spans[2];
1459 assert_eq!(name3, "no_decorator");
1460 assert!(lines[start3].trim().starts_with("def "));
1461 }
1462
1463 #[test]
1464 fn test_detect_fn_c_style_no_preprocessor() {
1465 let code = r#"
1466#define THING(x) { }
1467#define MACRO(a, b) { a + b; }
1468
1469int main(int argc, char** argv) {
1470 return 0;
1471}
1472"#;
1473 let lines: Vec<&str> = code.lines().collect();
1474 let spans = detect_fn_spans_c_style(&lines);
1475 assert_eq!(spans.len(), 1);
1477 assert_eq!(spans[0].2, "main");
1478 }
1479
1480 #[test]
1481 fn test_compute_technical_debt_ratio() {
1482 let export = ExportData {
1483 rows: vec![FileRow {
1484 path: "src/lib.rs".to_string(),
1485 module: "src".to_string(),
1486 lang: "Rust".to_string(),
1487 kind: FileKind::Parent,
1488 code: 1000,
1489 comments: 0,
1490 blanks: 0,
1491 lines: 1000,
1492 bytes: 1000,
1493 tokens: 250,
1494 }],
1495 module_roots: vec![],
1496 module_depth: 1,
1497 children: tokmd_types::ChildIncludeMode::Separate,
1498 };
1499
1500 let files = vec![FileComplexity {
1501 path: "src/lib.rs".to_string(),
1502 module: "src".to_string(),
1503 function_count: 3,
1504 max_function_length: 20,
1505 cyclomatic_complexity: 12,
1506 cognitive_complexity: Some(8),
1507 max_nesting: Some(2),
1508 risk_level: ComplexityRisk::Moderate,
1509 functions: None,
1510 }];
1511
1512 let debt = compute_technical_debt_ratio(&export, &files).expect("debt ratio");
1513 assert_eq!(debt.complexity_points, 20);
1514 assert!((debt.ratio - 20.0).abs() < f64::EPSILON);
1515 assert!((debt.code_kloc - 1.0).abs() < f64::EPSILON);
1516 assert_eq!(debt.level, TechnicalDebtLevel::Low);
1517 }
1518
1519 #[test]
1520 fn test_compute_technical_debt_ratio_none_for_zero_code() {
1521 let export = ExportData {
1522 rows: vec![FileRow {
1523 path: "src/lib.rs".to_string(),
1524 module: "src".to_string(),
1525 lang: "Rust".to_string(),
1526 kind: FileKind::Parent,
1527 code: 0,
1528 comments: 0,
1529 blanks: 0,
1530 lines: 0,
1531 bytes: 0,
1532 tokens: 0,
1533 }],
1534 module_roots: vec![],
1535 module_depth: 1,
1536 children: tokmd_types::ChildIncludeMode::Separate,
1537 };
1538
1539 let files = vec![FileComplexity {
1540 path: "src/lib.rs".to_string(),
1541 module: "src".to_string(),
1542 function_count: 1,
1543 max_function_length: 1,
1544 cyclomatic_complexity: 1,
1545 cognitive_complexity: Some(1),
1546 max_nesting: Some(1),
1547 risk_level: ComplexityRisk::Low,
1548 functions: None,
1549 }];
1550
1551 assert!(compute_technical_debt_ratio(&export, &files).is_none());
1552 }
1553}