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_line = lines[k - 1];
938 let prev_trimmed = prev_line.trim();
939
940 if prev_trimmed.is_empty() {
941 k -= 1;
942 continue;
943 }
944
945 if prev_trimmed.starts_with('#') {
946 k -= 1;
947 continue;
948 }
949
950 let prev_indent = prev_line.len() - prev_line.trim_start().len();
951 if prev_indent == base_indent && prev_trimmed.starts_with('@') {
952 start = k - 1;
953 k -= 1;
954 } else {
955 break;
956 }
957 }
958 }
959 let mut end = i;
960 let mut j = i + 1;
961 while j < lines.len() {
962 let lt = lines[j].trim();
963 if lt.is_empty() || lt.starts_with('#') {
964 j += 1;
965 continue;
966 }
967 let indent = lines[j].len() - lines[j].trim_start().len();
968 if indent <= base_indent {
969 break;
970 }
971 end = j;
972 j += 1;
973 }
974 spans.push((start, end, name));
975 i = end + 1;
976 } else {
977 i += 1;
978 }
979 }
980 spans
981}
982
983fn detect_fn_spans_go(lines: &[&str]) -> Vec<(usize, usize, String)> {
985 let mut spans = Vec::new();
986 let mut i = 0;
987 while i < lines.len() {
988 let trimmed = lines[i].trim();
989 if trimmed.starts_with("func ") {
990 let name = extract_go_fn_name(trimmed);
991 let start = i;
992 if let Some(end) = find_brace_end_at(lines, i) {
993 spans.push((start, end, name));
994 i = end + 1;
995 } else {
996 i += 1;
997 }
998 } else {
999 i += 1;
1000 }
1001 }
1002 spans
1003}
1004
1005fn detect_fn_spans_c_style(lines: &[&str]) -> Vec<(usize, usize, String)> {
1007 let mut spans = Vec::new();
1008 let mut i = 0;
1009 while i < lines.len() {
1010 let trimmed = lines[i].trim();
1011 let looks_like_fn = (trimmed.ends_with(") {") || trimmed.ends_with("){"))
1012 && !trimmed.starts_with("if ")
1013 && !trimmed.starts_with("if(")
1014 && !trimmed.starts_with("while ")
1015 && !trimmed.starts_with("for ")
1016 && !trimmed.starts_with("switch ")
1017 && !trimmed.starts_with("//")
1018 && !trimmed.starts_with('#');
1019 if looks_like_fn {
1020 let name = extract_c_fn_name(trimmed);
1021 let start = i;
1022 if let Some(end) = find_brace_end_at(lines, i) {
1023 spans.push((start, end, name));
1024 i = end + 1;
1025 } else {
1026 i += 1;
1027 }
1028 } else {
1029 i += 1;
1030 }
1031 }
1032 spans
1033}
1034
1035fn find_brace_end_at(lines: &[&str], start_line: usize) -> Option<usize> {
1040 let mut depth: usize = 0;
1041 let mut found_open = false;
1042 for (i, line) in lines.iter().enumerate().skip(start_line) {
1043 for ch in line.chars() {
1044 if ch == '{' {
1045 depth += 1;
1046 found_open = true;
1047 } else if ch == '}' {
1048 depth = depth.saturating_sub(1);
1049 if found_open && depth == 0 {
1050 return Some(i);
1051 }
1052 }
1053 }
1054 }
1055 None
1057}
1058
1059fn extract_rust_fn_name(line: &str) -> String {
1061 if let Some(idx) = line.find("fn ") {
1062 let after = &line[idx + 3..];
1063 let name: String = after
1064 .chars()
1065 .take_while(|c| c.is_alphanumeric() || *c == '_')
1066 .collect();
1067 if !name.is_empty() {
1068 return name;
1069 }
1070 }
1071 "<unknown>".to_string()
1072}
1073
1074fn extract_js_fn_name(line: &str) -> String {
1075 if let Some(idx) = line.find("function ") {
1076 let after = &line[idx + 9..];
1077 let name: String = after
1078 .chars()
1079 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
1080 .collect();
1081 if !name.is_empty() {
1082 return name;
1083 }
1084 }
1085 if let Some(paren_idx) = line.find('(') {
1086 let before = line[..paren_idx].trim();
1087 let name: String = before
1088 .chars()
1089 .rev()
1090 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
1091 .collect::<Vec<_>>()
1092 .into_iter()
1093 .rev()
1094 .collect();
1095 if !name.is_empty() {
1096 return name;
1097 }
1098 }
1099 "<anonymous>".to_string()
1100}
1101
1102fn extract_python_fn_name(line: &str) -> String {
1103 let keyword = if line.contains("async def ") {
1104 "async def "
1105 } else {
1106 "def "
1107 };
1108 if let Some(idx) = line.find(keyword) {
1109 let after = &line[idx + keyword.len()..];
1110 let name: String = after
1111 .chars()
1112 .take_while(|c| c.is_alphanumeric() || *c == '_')
1113 .collect();
1114 if !name.is_empty() {
1115 return name;
1116 }
1117 }
1118 "<unknown>".to_string()
1119}
1120
1121fn extract_go_fn_name(line: &str) -> String {
1122 if let Some(idx) = line.find("func ") {
1123 let after = &line[idx + 5..];
1124 let after = if after.starts_with('(') {
1125 if let Some(close) = after.find(')') {
1126 after[close + 1..].trim_start()
1127 } else {
1128 after
1129 }
1130 } else {
1131 after
1132 };
1133 let name: String = after
1134 .chars()
1135 .take_while(|c| c.is_alphanumeric() || *c == '_')
1136 .collect();
1137 if !name.is_empty() {
1138 return name;
1139 }
1140 }
1141 "<unknown>".to_string()
1142}
1143
1144fn extract_c_fn_name(line: &str) -> String {
1145 if let Some(paren_idx) = line.find('(') {
1146 let before = line[..paren_idx].trim();
1147 let name: String = before
1148 .chars()
1149 .rev()
1150 .take_while(|c| c.is_alphanumeric() || *c == '_')
1151 .collect::<Vec<_>>()
1152 .into_iter()
1153 .rev()
1154 .collect();
1155 if !name.is_empty() {
1156 return name;
1157 }
1158 }
1159 "<unknown>".to_string()
1160}
1161
1162fn count_params(line: &str) -> usize {
1164 if let Some(open) = line.find('(')
1165 && let Some(close) = line.find(')')
1166 {
1167 let params = line[open + 1..close].trim();
1168 if params.is_empty() {
1169 return 0;
1170 }
1171 return params.split(',').count();
1172 }
1173 0
1174}
1175
1176fn estimate_cyclomatic_inline(lang: &str, text: &str) -> usize {
1178 let mut complexity = 1usize;
1179 let keywords: &[&str] = match lang {
1180 "rust" => &["if ", "match ", "while ", "for ", "loop ", "?", "&&", "||"],
1181 "javascript" | "typescript" => {
1182 &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
1183 }
1184 "python" => &["if ", "elif ", "while ", "for ", "except ", " and ", " or "],
1185 "go" => &["if ", "case ", "for ", "select ", "&&", "||"],
1186 "c" | "c++" | "java" | "c#" | "php" => {
1187 &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
1188 }
1189 _ => &[],
1190 };
1191 let lower = text.to_lowercase();
1192 for keyword in keywords {
1193 complexity += lower.matches(keyword).count();
1194 }
1195 complexity
1196}
1197
1198fn round_f64(val: f64, decimals: u32) -> f64 {
1199 let factor = 10f64.powi(decimals as i32);
1200 (val * factor).round() / factor
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205 use super::*;
1206
1207 #[test]
1208 fn test_count_rust_functions() {
1209 let code = r#"
1210fn simple() {
1211 println!("hello");
1212}
1213
1214pub fn public_fn() {
1215 let x = 1;
1216 let y = 2;
1217}
1218
1219pub async fn async_fn() {
1220 todo!()
1221}
1222"#;
1223 let lines: Vec<&str> = code.lines().collect();
1224 let (count, _max_len) = count_rust_functions(&lines);
1225 assert_eq!(count, 3);
1226 }
1227
1228 #[test]
1229 fn test_count_python_functions() {
1230 let code = r#"
1231def foo():
1232 pass
1233
1234async def bar():
1235 await something()
1236
1237def baz():
1238 x = 1
1239 y = 2
1240 return x + y
1241"#;
1242 let lines: Vec<&str> = code.lines().collect();
1243 let (count, _max_len) = count_python_functions(&lines);
1244 assert_eq!(count, 3);
1245 }
1246
1247 #[test]
1248 fn test_estimate_cyclomatic_rust() {
1249 let code = r#"
1250fn complex(x: i32) -> i32 {
1251 if x > 0 {
1252 if x > 10 {
1253 x * 2
1254 } else {
1255 x + 1
1256 }
1257 } else {
1258 match x {
1259 -1 => 0,
1260 _ => x.abs(),
1261 }
1262 }
1263}
1264"#;
1265 let cyclo = estimate_cyclomatic("rust", code);
1266 assert_eq!(cyclo, 4);
1268 }
1269
1270 #[test]
1271 fn test_estimate_cyclomatic_rust_no_else_if_double_count() {
1272 let code = r#"
1274fn branchy(x: i32) -> i32 {
1275 if x > 0 {
1276 1
1277 } else if x < 0 {
1278 -1
1279 } else if x == 0 {
1280 0
1281 } else {
1282 42
1283 }
1284}
1285"#;
1286 let cyclo = estimate_cyclomatic("rust", code);
1287 assert_eq!(cyclo, 4);
1289 }
1290
1291 #[test]
1292 fn test_estimate_cyclomatic_js_no_switch_double_count() {
1293 let code = r#"
1295function classify(x) {
1296 switch (x) {
1297 case 1: return "one";
1298 case 2: return "two";
1299 case 3: return "three";
1300 default: return "other";
1301 }
1302}
1303"#;
1304 let cyclo = estimate_cyclomatic("javascript", code);
1305 assert_eq!(cyclo, 4);
1307 }
1308
1309 #[test]
1310 fn test_classify_risk() {
1311 assert_eq!(
1312 classify_risk_extended(5, 10, 5, None, None),
1313 ComplexityRisk::Low
1314 );
1315 assert_eq!(
1316 classify_risk_extended(25, 30, 15, None, None),
1317 ComplexityRisk::Moderate
1318 );
1319 assert_eq!(
1320 classify_risk_extended(30, 60, 25, None, None),
1321 ComplexityRisk::High
1322 );
1323 assert_eq!(
1324 classify_risk_extended(60, 120, 60, None, None),
1325 ComplexityRisk::Critical
1326 );
1327 }
1328
1329 #[test]
1330 fn test_classify_risk_with_cognitive() {
1331 assert_eq!(
1333 classify_risk_extended(5, 10, 5, Some(10), Some(2)),
1334 ComplexityRisk::Low
1335 );
1336 assert!(matches!(
1338 classify_risk_extended(5, 10, 5, Some(60), Some(6)),
1339 ComplexityRisk::Moderate | ComplexityRisk::High
1340 ));
1341 assert!(matches!(
1343 classify_risk_extended(5, 10, 5, Some(10), Some(9)),
1344 ComplexityRisk::Moderate | ComplexityRisk::High
1345 ));
1346 }
1347
1348 #[test]
1349 fn test_is_complexity_lang() {
1350 assert!(is_complexity_lang("Rust"));
1351 assert!(is_complexity_lang("javascript"));
1352 assert!(is_complexity_lang("Python"));
1353 assert!(!is_complexity_lang("Markdown"));
1354 assert!(!is_complexity_lang("JSON"));
1355 }
1356
1357 #[test]
1358 fn test_is_rust_fn_start_extended() {
1359 assert!(is_rust_fn_start("fn foo()"));
1361 assert!(is_rust_fn_start("pub fn foo()"));
1362 assert!(is_rust_fn_start("pub(crate) fn foo()"));
1363 assert!(is_rust_fn_start("pub(super) fn foo()"));
1364 assert!(is_rust_fn_start("async fn foo()"));
1365 assert!(is_rust_fn_start("pub async fn foo()"));
1366 assert!(is_rust_fn_start("unsafe fn foo()"));
1367 assert!(is_rust_fn_start("const fn foo()"));
1368
1369 assert!(is_rust_fn_start("pub(in crate::foo) fn bar()"));
1371 assert!(is_rust_fn_start("pub(in crate::foo::bar) fn baz()"));
1372
1373 assert!(is_rust_fn_start(r#"extern "C" fn callback()"#));
1375 assert!(is_rust_fn_start(r#"pub extern "C" fn callback()"#));
1376 assert!(is_rust_fn_start(r#"pub unsafe extern "C" fn callback()"#));
1377
1378 assert!(is_rust_fn_start("pub(crate) unsafe async fn baz()"));
1380 assert!(is_rust_fn_start("pub(super) const fn helper()"));
1381
1382 assert!(!is_rust_fn_start("let fn_name = 5;"));
1384 assert!(!is_rust_fn_start("// fn foo()"));
1385 assert!(!is_rust_fn_start("struct Foo {"));
1386 }
1387
1388 #[test]
1389 fn test_detect_fn_rust_qualifiers() {
1390 let code = r#"
1391pub(crate) async fn crate_async() {
1392 todo!()
1393}
1394
1395pub(super) async fn super_async() {
1396 todo!()
1397}
1398
1399pub(crate) unsafe fn crate_unsafe() {
1400 todo!()
1401}
1402
1403pub unsafe fn public_unsafe() {
1404 todo!()
1405}
1406
1407pub(crate) const fn crate_const() -> u32 {
1408 42
1409}
1410
1411pub const fn public_const() -> u32 {
1412 0
1413}
1414"#;
1415 let lines: Vec<&str> = code.lines().collect();
1416 let spans = detect_fn_spans_rust(&lines);
1417 let names: Vec<&str> = spans.iter().map(|(_, _, n)| n.as_str()).collect();
1418 assert_eq!(
1419 names,
1420 vec![
1421 "crate_async",
1422 "super_async",
1423 "crate_unsafe",
1424 "public_unsafe",
1425 "crate_const",
1426 "public_const",
1427 ]
1428 );
1429
1430 let (count, _) = count_rust_functions(&lines);
1432 assert_eq!(count, 6);
1433 }
1434
1435 #[test]
1436 fn test_detect_fn_python_decorators() {
1437 let code = r#"
1438@staticmethod
1439def plain_static():
1440 pass
1441
1442@app.route("/")
1443@login_required
1444def index():
1445 return "hello"
1446
1447def no_decorator():
1448 pass
1449"#;
1450 let lines: Vec<&str> = code.lines().collect();
1451 let spans = detect_fn_spans_python(&lines);
1452 assert_eq!(spans.len(), 3);
1453
1454 let (start, _end, ref name) = spans[0];
1456 assert_eq!(name, "plain_static");
1457 assert!(lines[start].trim().starts_with('@'));
1459
1460 let (start2, _end2, ref name2) = spans[1];
1462 assert_eq!(name2, "index");
1463 assert!(lines[start2].trim().starts_with('@'));
1464
1465 let (start3, _end3, ref name3) = spans[2];
1467 assert_eq!(name3, "no_decorator");
1468 assert!(lines[start3].trim().starts_with("def "));
1469 }
1470
1471 #[test]
1472 fn test_detect_fn_c_style_no_preprocessor() {
1473 let code = r#"
1474#define THING(x) { }
1475#define MACRO(a, b) { a + b; }
1476
1477int main(int argc, char** argv) {
1478 return 0;
1479}
1480"#;
1481 let lines: Vec<&str> = code.lines().collect();
1482 let spans = detect_fn_spans_c_style(&lines);
1483 assert_eq!(spans.len(), 1);
1485 assert_eq!(spans[0].2, "main");
1486 }
1487
1488 #[test]
1489 fn test_compute_technical_debt_ratio() {
1490 let export = ExportData {
1491 rows: vec![FileRow {
1492 path: "src/lib.rs".to_string(),
1493 module: "src".to_string(),
1494 lang: "Rust".to_string(),
1495 kind: FileKind::Parent,
1496 code: 1000,
1497 comments: 0,
1498 blanks: 0,
1499 lines: 1000,
1500 bytes: 1000,
1501 tokens: 250,
1502 }],
1503 module_roots: vec![],
1504 module_depth: 1,
1505 children: tokmd_types::ChildIncludeMode::Separate,
1506 };
1507
1508 let files = vec![FileComplexity {
1509 path: "src/lib.rs".to_string(),
1510 module: "src".to_string(),
1511 function_count: 3,
1512 max_function_length: 20,
1513 cyclomatic_complexity: 12,
1514 cognitive_complexity: Some(8),
1515 max_nesting: Some(2),
1516 risk_level: ComplexityRisk::Moderate,
1517 functions: None,
1518 }];
1519
1520 let debt = compute_technical_debt_ratio(&export, &files).expect("debt ratio");
1521 assert_eq!(debt.complexity_points, 20);
1522 assert!((debt.ratio - 20.0).abs() < f64::EPSILON);
1523 assert!((debt.code_kloc - 1.0).abs() < f64::EPSILON);
1524 assert_eq!(debt.level, TechnicalDebtLevel::Low);
1525 }
1526
1527 #[test]
1528 fn test_compute_technical_debt_ratio_none_for_zero_code() {
1529 let export = ExportData {
1530 rows: vec![FileRow {
1531 path: "src/lib.rs".to_string(),
1532 module: "src".to_string(),
1533 lang: "Rust".to_string(),
1534 kind: FileKind::Parent,
1535 code: 0,
1536 comments: 0,
1537 blanks: 0,
1538 lines: 0,
1539 bytes: 0,
1540 tokens: 0,
1541 }],
1542 module_roots: vec![],
1543 module_depth: 1,
1544 children: tokmd_types::ChildIncludeMode::Separate,
1545 };
1546
1547 let files = vec![FileComplexity {
1548 path: "src/lib.rs".to_string(),
1549 module: "src".to_string(),
1550 function_count: 1,
1551 max_function_length: 1,
1552 cyclomatic_complexity: 1,
1553 cognitive_complexity: Some(1),
1554 max_nesting: Some(1),
1555 risk_level: ComplexityRisk::Low,
1556 functions: None,
1557 }];
1558
1559 assert!(compute_technical_debt_ratio(&export, &files).is_none());
1560 }
1561
1562 #[test]
1563 fn test_detect_fn_python_decorators_extended() {
1564 let code = r#"
1565@app.route("/")
1566# This is a comment between decorators
1567@login_required
1568
1569# Another comment
1570def index():
1571 return "hello"
1572
1573@nested_decorator
1574# Indented comment
1575def nested():
1576 pass
1577"#;
1578 let lines: Vec<&str> = code.lines().collect();
1579 let spans = detect_fn_spans_python(&lines);
1580 assert_eq!(spans.len(), 2);
1581
1582 let (start, _end, ref name) = spans[0];
1584 assert_eq!(name, "index");
1585 assert!(lines[start].trim().starts_with("@app.route"));
1587
1588 let (start2, _end2, ref name2) = spans[1];
1590 assert_eq!(name2, "nested");
1591 assert!(lines[start2].trim().starts_with("@nested_decorator"));
1593 }
1594}