Skip to main content

tokmd_analysis_complexity/
lib.rs

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
20/// Map language strings to complexity-compatible names.
21fn 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
38/// Languages that support complexity analysis.
39fn 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
56/// Build a complexity report by analyzing function counts, lengths, cyclomatic and cognitive complexity.
57pub 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        // Compute cognitive complexity and nesting depth
104        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    // Sort by cyclomatic complexity descending, then by path
147    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    // Compute aggregates before truncating
154    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    // Compute cognitive complexity aggregates
190    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    // Compute nesting depth aggregates
206    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    // Generate histogram from all files before truncating
232    let histogram = generate_complexity_histogram(&file_complexities, 5);
233
234    // Compute maintainability index
235    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    // Only keep top files by complexity
244    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, // Populated when halstead feature is enabled
259        maintainability_index,
260        technical_debt,
261        files: file_complexities,
262    })
263}
264
265/// Generate a histogram of cyclomatic complexity distribution.
266///
267/// Buckets files by cyclomatic complexity: 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30+.
268///
269/// # Arguments
270/// * `files` - Slice of file complexity data
271/// * `bucket_size` - Size of each bucket (default 5)
272///
273/// # Returns
274/// A `ComplexityHistogram` with counts for each bucket
275///
276/// # Note
277/// This function is planned for integration in v1.6.0.
278pub fn generate_complexity_histogram(
279    files: &[FileComplexity],
280    bucket_size: u32,
281) -> ComplexityHistogram {
282    // 7 buckets: 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30+
283    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
299/// Count functions and estimate max function length in lines.
300fn 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
313/// Check if a trimmed line starts a Rust function definition.
314///
315/// Handles all visibility qualifiers including `pub(in path::here)`,
316/// optional `async`, `unsafe`, `const`, and `extern "ABI"` modifiers.
317fn is_rust_fn_start(trimmed: &str) -> bool {
318    // Fast path: find "fn " in the line
319    let Some(fn_pos) = trimmed.find("fn ") else {
320        return false;
321    };
322
323    // Everything before "fn " must be valid qualifiers
324    let prefix = trimmed[..fn_pos].trim();
325    if prefix.is_empty() {
326        return true; // bare "fn name"
327    }
328
329    // Parse prefix: valid tokens are pub/pub(...), async, unsafe, const, extern "..."
330    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            // Skip pub(...) with arbitrary content (e.g., pub(in crate::foo))
338            if let Some(close) = rest.find(')') {
339                rest = &rest[close + 1..];
340            } else {
341                return false; // Unclosed paren
342            }
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            // extern "ABI" - skip the ABI string
353            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; // Unclosed string
359                }
360            }
361        } else {
362            return false; // Unknown token
363        }
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        // Detect function start
382        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; // Line comment
408                }
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        // Detect function declarations
454        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                // Previous function ended
499                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        // Heuristic: line ends with ) { or ) followed by { on next line
570        let looks_like_fn = trimmed.ends_with(") {")
571            || (trimmed.ends_with(')') && i + 1 < lines.len() && lines[i + 1].trim() == "{");
572
573        // Exclude control structures
574        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            // Count nested blocks
626            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
653/// Estimate cyclomatic complexity by counting branching keywords.
654fn estimate_cyclomatic(lang: &str, text: &str) -> usize {
655    // Base complexity is 1
656    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
683/// Classify risk based on complexity metrics including cognitive and nesting.
684fn 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    // Risk factors
692    let mut score = 0;
693
694    // Function count risk
695    if function_count > 50 {
696        score += 2;
697    } else if function_count > 20 {
698        score += 1;
699    }
700
701    // Function length risk (long functions are harder to maintain)
702    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    // Cyclomatic complexity risk
711    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    // Cognitive complexity risk (higher thresholds than cyclomatic)
720    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    // Nesting depth risk
731    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
773/// Compute a complexity-to-size heuristic debt ratio.
774///
775/// Ratio = (sum cyclomatic + cognitive complexity points) / KLOC
776fn 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
819/// Extract function-level complexity details for a source file.
820fn 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
872/// Detect Rust function spans: (start_line, end_line, name).
873fn 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                // No body found (trait sig, abstract, extern) — skip
886                i += 1;
887            }
888        } else {
889            i += 1;
890        }
891    }
892    spans
893}
894
895/// Detect JS/TS function spans.
896fn 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
922/// Detect Python function spans.
923fn 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            // Walk upward to include decorator lines (@...)
933            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
983/// Detect Go function spans.
984fn 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
1005/// Detect C-style function spans.
1006fn 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
1035/// Find closing brace for a block starting at `start_line`.
1036///
1037/// Returns `None` if no opening brace is found (e.g., trait method
1038/// signatures, extern declarations, abstract methods).
1039fn 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    // Both cases (no open brace, or unclosed braces) → None
1056    None
1057}
1058
1059/// Extract Rust function name from a line containing "fn ".
1060fn 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
1162/// Count function parameters from a line.
1163fn 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
1176/// Estimate cyclomatic complexity for a function body.
1177fn 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        // Base 1 + 2 ifs + 1 match = 4
1267        assert_eq!(cyclo, 4);
1268    }
1269
1270    #[test]
1271    fn test_estimate_cyclomatic_rust_no_else_if_double_count() {
1272        // "else if" should only count once (as "if"), not as both "if" and "else if"
1273        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        // Base 1 + 3 ifs (the initial "if" + 2 "else if" each matched by "if ")
1288        assert_eq!(cyclo, 4);
1289    }
1290
1291    #[test]
1292    fn test_estimate_cyclomatic_js_no_switch_double_count() {
1293        // "switch" removed; only "case" contributes
1294        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        // Base 1 + 3 cases = 4
1306        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        // Low cognitive should not change low risk
1332        assert_eq!(
1333            classify_risk_extended(5, 10, 5, Some(10), Some(2)),
1334            ComplexityRisk::Low
1335        );
1336        // High cognitive should increase risk
1337        assert!(matches!(
1338            classify_risk_extended(5, 10, 5, Some(60), Some(6)),
1339            ComplexityRisk::Moderate | ComplexityRisk::High
1340        ));
1341        // High nesting should increase risk
1342        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        // Standard cases
1360        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        // Extended: pub(in path) visibility
1370        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        // Extended: extern "ABI" functions
1374        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        // Extended: multi-qualifier combos
1379        assert!(is_rust_fn_start("pub(crate) unsafe async fn baz()"));
1380        assert!(is_rust_fn_start("pub(super) const fn helper()"));
1381
1382        // Negative cases
1383        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        // Also verify count_rust_functions picks them all up
1431        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        // First function: @staticmethod + def plain_static
1455        let (start, _end, ref name) = spans[0];
1456        assert_eq!(name, "plain_static");
1457        // The span should start at the decorator line
1458        assert!(lines[start].trim().starts_with('@'));
1459
1460        // Second function: two decorators + def index
1461        let (start2, _end2, ref name2) = spans[1];
1462        assert_eq!(name2, "index");
1463        assert!(lines[start2].trim().starts_with('@'));
1464
1465        // Third function: no decorator
1466        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        // Should only detect main, not #define macros
1484        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        // First function: index
1583        let (start, _end, ref name) = spans[0];
1584        assert_eq!(name, "index");
1585        // Should start at @app.route
1586        assert!(lines[start].trim().starts_with("@app.route"));
1587
1588        // Second function: nested
1589        let (start2, _end2, ref name2) = spans[1];
1590        assert_eq!(name2, "nested");
1591        // Should start at @nested_decorator
1592        assert!(lines[start2].trim().starts_with("@nested_decorator"));
1593    }
1594}