use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use regex::Regex;
use serde::{Deserialize, Serialize};
use tree_sitter::Node;
use rayon::prelude::*;
use crate::ast::function_finder::{
get_function_body as shared_get_function_body, get_function_node_kinds_vec,
};
use crate::ast::parser::parse;
use crate::metrics::calculate_all_complexities_from_tree;
use crate::quality::cohesion::extract_self_accesses;
use crate::types::Language;
use crate::TldrResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DebtCategory {
Reliability,
Security,
Maintainability,
Efficiency,
Changeability,
Testability,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DebtRule {
ComplexityHigh,
ComplexityVeryHigh,
ComplexityExtreme,
GodClass,
LongMethod,
LongParamList,
DeepNesting,
TodoComment,
HighCoupling,
MissingDocs,
}
impl DebtRule {
pub fn minutes(&self) -> u32 {
match self {
Self::ComplexityHigh => 20,
Self::ComplexityVeryHigh => 30,
Self::ComplexityExtreme => 60,
Self::GodClass => 60,
Self::LongMethod => 30,
Self::LongParamList => 15,
Self::DeepNesting => 15,
Self::TodoComment => 10,
Self::HighCoupling => 20,
Self::MissingDocs => 10,
}
}
pub fn category(&self) -> DebtCategory {
match self {
Self::ComplexityHigh
| Self::ComplexityVeryHigh
| Self::ComplexityExtreme
| Self::LongMethod
| Self::DeepNesting
| Self::MissingDocs => DebtCategory::Maintainability,
Self::GodClass | Self::HighCoupling => DebtCategory::Changeability,
Self::LongParamList => DebtCategory::Testability,
Self::TodoComment => DebtCategory::Reliability,
}
}
pub fn description(&self) -> &'static str {
match self {
Self::ComplexityHigh => "Cyclomatic complexity > 10",
Self::ComplexityVeryHigh => "Cyclomatic complexity > 15",
Self::ComplexityExtreme => "Cyclomatic complexity > 25",
Self::GodClass => "Large class with low cohesion",
Self::LongMethod => "Method too long (LOC > 100)",
Self::LongParamList => "Too many parameters (> 5)",
Self::DeepNesting => "Nesting > 4 levels",
Self::TodoComment => "TODO/FIXME/HACK comment",
Self::HighCoupling => "Module coupling too high",
Self::MissingDocs => "Public API undocumented",
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::ComplexityHigh => "complexity.high",
Self::ComplexityVeryHigh => "complexity.very_high",
Self::ComplexityExtreme => "complexity.extreme",
Self::GodClass => "god_class",
Self::LongMethod => "long_method",
Self::LongParamList => "long_param_list",
Self::DeepNesting => "deep_nesting",
Self::TodoComment => "todo_comment",
Self::HighCoupling => "high_coupling",
Self::MissingDocs => "missing_docs",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebtIssue {
pub file: PathBuf,
pub line: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub element: Option<String>,
pub rule: String,
pub message: String,
pub category: String,
pub debt_minutes: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDebt {
pub file: PathBuf,
pub total_minutes: u32,
pub issue_count: usize,
#[serde(skip)]
pub issues: Vec<DebtIssue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebtSummary {
pub total_minutes: u32,
pub total_hours: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
pub debt_ratio: f64,
pub debt_density: f64,
pub by_category: BTreeMap<String, u32>,
pub by_rule: BTreeMap<String, u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebtReport {
pub issues: Vec<DebtIssue>,
pub top_files: Vec<FileDebt>,
pub summary: DebtSummary,
}
impl DebtReport {
pub fn to_text(&self) -> String {
let s = &self.summary;
let mut lines = vec![
"Technical Debt Report".to_string(),
"=".repeat(50),
format!(
"Total Debt: {:.1} hours ({} minutes)",
s.total_hours, s.total_minutes
),
];
if let Some(cost) = s.total_cost {
lines.push(format!("Estimated Cost: ${:.2}", cost));
}
let rating = if s.debt_ratio < 0.05 {
"Excellent"
} else if s.debt_ratio < 0.10 {
"Good"
} else if s.debt_ratio < 0.20 {
"Concerning"
} else {
"Critical"
};
lines.push(format!(
"Debt Ratio: {:.1}% ({})",
s.debt_ratio * 100.0,
rating
));
lines.push(format!(
"Debt Density: {:.1} minutes per KLOC",
s.debt_density
));
if !s.by_category.is_empty() {
lines.push(String::new());
lines.push("By Category:".to_string());
let mut sorted_cats: Vec<_> = s.by_category.iter().collect();
sorted_cats.sort_by(|a, b| b.1.cmp(a.1));
for (cat, minutes) in sorted_cats {
let hours = *minutes as f64 / 60.0;
let pct = if s.total_minutes > 0 {
(*minutes as f64 / s.total_minutes as f64) * 100.0
} else {
0.0
};
let cat_title: String = cat
.chars()
.next()
.map(|c| c.to_uppercase().collect::<String>())
.unwrap_or_default()
+ &cat[1..];
lines.push(format!(" {}: {:.1}h ({:.0}%)", cat_title, hours, pct));
}
}
if !self.top_files.is_empty() {
lines.push(String::new());
lines.push("Top Debtors:".to_string());
for (i, f) in self.top_files.iter().take(10).enumerate() {
let hours = f.total_minutes as f64 / 60.0;
let fname = f
.file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
lines.push(format!(
" {}. {} - {:.1}h ({} issues)",
i + 1,
fname,
hours,
f.issue_count
));
}
}
if !self.issues.is_empty() {
lines.push(String::new());
lines.push("Top Issues:".to_string());
for issue in self.issues.iter().take(10) {
let fname = issue
.file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let cat = issue.category.to_uppercase();
let loc = format!("{}:{}", fname, issue.line);
lines.push(format!(
" [{}] {} - {} ({}m)",
cat, loc, issue.message, issue.debt_minutes
));
}
}
lines.join("\n")
}
}
#[derive(Debug, Clone)]
pub struct DebtOptions {
pub path: PathBuf,
pub category_filter: Option<String>,
pub top_k: usize,
pub hourly_rate: Option<f64>,
pub min_debt: u32,
pub language: Option<Language>,
}
impl Default for DebtOptions {
fn default() -> Self {
Self {
path: PathBuf::from("."),
category_filter: None,
top_k: 20,
hourly_rate: None,
min_debt: 0,
language: None,
}
}
}
pub fn count_loc(source: &str, language: Language) -> usize {
let mut count = 0;
let mut in_multiline_string = false;
let mut multiline_quote_style: Option<&str> = None;
for line in source.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if language == Language::Python {
let double_quote_count = trimmed.matches("\"\"\"").count();
let single_quote_count = trimmed.matches("'''").count();
if in_multiline_string {
let quote_style = multiline_quote_style.unwrap_or("\"\"\"");
let quote_count = if quote_style == "\"\"\"" {
double_quote_count
} else {
single_quote_count
};
if quote_count >= 1 {
in_multiline_string = false;
multiline_quote_style = None;
}
continue;
}
if double_quote_count >= 2 || single_quote_count >= 2 {
continue;
} else if double_quote_count == 1 {
in_multiline_string = true;
multiline_quote_style = Some("\"\"\"");
continue;
} else if single_quote_count == 1 {
in_multiline_string = true;
multiline_quote_style = Some("'''");
continue;
}
}
if is_comment_line(trimmed, language) {
continue;
}
count += 1;
}
count
}
fn is_comment_line(trimmed: &str, language: Language) -> bool {
match language {
Language::Python => trimmed.starts_with('#'),
Language::Ruby => trimmed.starts_with('#'),
Language::Elixir => trimmed.starts_with('#'),
Language::Lua | Language::Luau => trimmed.starts_with("--"),
Language::Ocaml => {
trimmed.starts_with("(*") || trimmed.starts_with("*)") || trimmed.starts_with('*')
}
Language::Php => {
trimmed.starts_with("//")
|| trimmed.starts_with('#')
|| trimmed.starts_with("/*")
|| trimmed.starts_with("*/")
|| trimmed.starts_with("*")
}
Language::Rust
| Language::Go
| Language::TypeScript
| Language::JavaScript
| Language::Java
| Language::C
| Language::Cpp
| Language::Kotlin
| Language::Swift
| Language::CSharp
| Language::Scala => {
trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with("*/")
|| trimmed.starts_with("*")
}
}
}
fn comment_node_kinds(language: Language) -> &'static [&'static str] {
match language {
Language::Python => &["comment"],
Language::TypeScript | Language::JavaScript => &["comment"],
Language::Go => &["comment"],
Language::Rust => &["line_comment", "block_comment"],
Language::Java => &["line_comment", "block_comment"],
Language::C | Language::Cpp => &["comment"],
Language::Ruby => &["comment"],
Language::Kotlin => &["line_comment", "multiline_comment"],
Language::Swift => &["comment", "multiline_comment"],
Language::CSharp => &["comment"],
Language::Scala => &["comment"],
Language::Php => &["comment"],
Language::Lua | Language::Luau => &["comment"],
Language::Elixir => &["comment"],
Language::Ocaml => &["comment"],
}
}
fn strip_comment_prefix(text: &str) -> &str {
let trimmed = text.trim();
if let Some(rest) = trimmed.strip_prefix("//") {
return rest.trim_start();
}
if let Some(rest) = trimmed.strip_prefix("/*") {
let rest = rest.strip_suffix("*/").unwrap_or(rest);
return rest.trim();
}
if let Some(rest) = trimmed.strip_prefix("(*") {
let rest = rest.strip_suffix("*)").unwrap_or(rest);
return rest.trim();
}
if let Some(rest) = trimmed.strip_prefix("--") {
return rest.trim_start();
}
if let Some(rest) = trimmed.strip_prefix('#') {
return rest.trim_start();
}
trimmed
}
pub fn find_todo_comments(source: &str, filepath: &Path, language: Language) -> Vec<DebtIssue> {
find_todo_comments_inner(source, filepath, language, None)
}
fn find_todo_comments_inner(
source: &str,
filepath: &Path,
language: Language,
pre_parsed: Option<&tree_sitter::Tree>,
) -> Vec<DebtIssue> {
if let Some(tree) = pre_parsed {
find_todo_comments_ast(source, filepath, language, tree)
} else {
match parse(source, language) {
Ok(tree) => find_todo_comments_ast(source, filepath, language, &tree),
Err(_) => find_todo_comments_regex(source, filepath, language),
}
}
}
fn find_todo_comments_ast(
source: &str,
filepath: &Path,
language: Language,
tree: &tree_sitter::Tree,
) -> Vec<DebtIssue> {
let mut issues = Vec::new();
let comment_kinds = comment_node_kinds(language);
let tag_pattern = Regex::new(r"(?i)^(TODO|FIXME|HACK|XXX)\b[\s:]*(.*)").unwrap();
let mut visit_stack = vec![tree.root_node()];
while let Some(node) = visit_stack.pop() {
if comment_kinds.contains(&node.kind()) {
let start = node.start_byte();
let end = node.end_byte();
if start < source.len() && end <= source.len() {
let comment_text = &source[start..end];
for (offset, line) in comment_text.lines().enumerate() {
let stripped = strip_comment_prefix(line);
let stripped = stripped
.strip_prefix('*')
.map(|s| s.trim_start())
.unwrap_or(stripped);
if let Some(captures) = tag_pattern.captures(stripped) {
let tag = captures
.get(1)
.map(|m| m.as_str().to_uppercase())
.unwrap_or_default();
let content = captures
.get(2)
.map(|m| m.as_str().trim())
.unwrap_or("")
.chars()
.take(50)
.collect::<String>();
let message = if content.is_empty() {
tag.clone()
} else {
format!("{}: {}", tag, content)
};
let line_num = node.start_position().row + offset;
issues.push(DebtIssue {
file: filepath.to_path_buf(),
line: (line_num + 1) as u32, element: None,
rule: "todo_comment".to_string(),
message,
category: "reliability".to_string(),
debt_minutes: 10,
});
}
}
}
}
let child_count = node.child_count();
for i in (0..child_count).rev() {
if let Some(child) = node.child(i) {
visit_stack.push(child);
}
}
}
issues.sort_by_key(|i| i.line);
issues
}
fn find_todo_comments_regex(source: &str, filepath: &Path, language: Language) -> Vec<DebtIssue> {
let mut issues = Vec::new();
let pattern_str = match language {
Language::Python | Language::Ruby | Language::Elixir => {
r"(?i)#\s*(TODO|FIXME|HACK|XXX)\b[\s:]*(.*)$"
}
Language::Lua | Language::Luau => r"(?i)--\s*(TODO|FIXME|HACK|XXX)\b[\s:]*(.*)$",
Language::Ocaml => r"(?i)\(\*\s*(TODO|FIXME|HACK|XXX)\b[\s:]*(.*)$",
Language::Php => {
r"(?i)(?://|#)\s*(TODO|FIXME|HACK|XXX)\b[\s:]*(.*)$"
}
_ => {
r"(?i)//\s*(TODO|FIXME|HACK|XXX)\b[\s:]*(.*)$"
}
};
let pattern = Regex::new(pattern_str).unwrap();
for (line_num, line) in source.lines().enumerate() {
if let Some(captures) = pattern.captures(line) {
let tag = captures
.get(1)
.map(|m| m.as_str().to_uppercase())
.unwrap_or_default();
let content = captures
.get(2)
.map(|m| m.as_str().trim())
.unwrap_or("")
.chars()
.take(50)
.collect::<String>();
let message = if content.is_empty() {
tag.clone()
} else {
format!("{}: {}", tag, content)
};
issues.push(DebtIssue {
file: filepath.to_path_buf(),
line: (line_num + 1) as u32, element: None,
rule: "todo_comment".to_string(),
message,
category: "reliability".to_string(),
debt_minutes: 10,
});
}
}
issues
}
pub fn find_complexity_issues(source: &str, filepath: &Path, language: Language) -> Vec<DebtIssue> {
find_complexity_issues_inner(source, filepath, language, None)
}
fn find_complexity_issues_inner(
source: &str,
filepath: &Path,
language: Language,
pre_parsed: Option<&tree_sitter::Tree>,
) -> Vec<DebtIssue> {
let mut issues = Vec::new();
let owned_tree;
let tree = match pre_parsed {
Some(t) => t,
None => {
owned_tree = match parse(source, language) {
Ok(t) => t,
Err(_) => return issues,
};
&owned_tree
}
};
let root = tree.root_node();
let function_infos = extract_function_infos_for_debt(root, source, language);
let complexity_map =
calculate_all_complexities_from_tree(root, source, language).unwrap_or_default();
for func_info in function_infos {
let file = filepath.to_path_buf();
if let Some(metrics) = complexity_map.get(&func_info.name) {
let cc = metrics.cyclomatic;
let complexity_issue = if cc > 25 {
Some((
"complexity.extreme",
60,
format!("Cyclomatic complexity {} exceeds threshold", cc),
))
} else if cc > 15 {
Some((
"complexity.very_high",
30,
format!("Cyclomatic complexity {} exceeds threshold", cc),
))
} else if cc > 10 {
Some((
"complexity.high",
20,
format!("Cyclomatic complexity {} exceeds threshold", cc),
))
} else {
None
};
if let Some((rule, minutes, message)) = complexity_issue {
issues.push(DebtIssue {
file: file.clone(),
line: func_info.start_line,
element: Some(func_info.full_name.clone()),
rule: rule.to_string(),
message,
category: "maintainability".to_string(),
debt_minutes: minutes,
});
}
}
let func_loc = func_info.end_line.saturating_sub(func_info.start_line);
if func_loc > 100 {
issues.push(DebtIssue {
file: file.clone(),
line: func_info.start_line,
element: Some(func_info.full_name.clone()),
rule: "long_method".to_string(),
message: format!("Method has {} lines (> 100)", func_loc),
category: "maintainability".to_string(),
debt_minutes: 30,
});
}
let param_count = count_params_excluding_self(&func_info.params);
if param_count > 5 {
issues.push(DebtIssue {
file: file.clone(),
line: func_info.start_line,
element: Some(func_info.full_name.clone()),
rule: "long_param_list".to_string(),
message: format!("Function has {} parameters (> 5)", param_count),
category: "testability".to_string(),
debt_minutes: 15,
});
}
}
issues
}
#[derive(Debug)]
struct FunctionInfoForDebt {
name: String,
full_name: String,
start_line: u32,
end_line: u32,
params: Vec<String>,
}
fn extract_function_infos_for_debt(
root: Node,
source: &str,
language: Language,
) -> Vec<FunctionInfoForDebt> {
let mut functions = Vec::new();
match language {
Language::Python => extract_python_functions_for_debt(root, source, &mut functions, None),
Language::TypeScript | Language::JavaScript => {
extract_ts_functions_for_debt(root, source, &mut functions, None)
}
Language::Go => extract_go_functions_for_debt(root, source, &mut functions),
Language::Rust => extract_rust_functions_for_debt(root, source, &mut functions, None),
Language::Java => extract_java_functions_for_debt(root, source, &mut functions, None),
_ => {} }
functions
}
fn extract_python_functions_for_debt(
node: Node,
source: &str,
functions: &mut Vec<FunctionInfoForDebt>,
class_name: Option<&str>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"function_definition" => {
if let Some(info) =
extract_python_function_info_for_debt(&child, source, class_name)
{
functions.push(info);
}
}
"decorated_definition" => {
if let Some(def) = child.child_by_field_name("definition") {
if def.kind() == "function_definition" {
if let Some(info) =
extract_python_function_info_for_debt(&def, source, class_name)
{
functions.push(info);
}
}
}
}
"class_definition" => {
if let Some(name_node) = child.child_by_field_name("name") {
let cls_name = get_node_text(&name_node, source);
if let Some(body) = child.child_by_field_name("body") {
extract_python_functions_for_debt(body, source, functions, Some(&cls_name));
}
}
}
_ => {
if class_name.is_none() {
extract_python_functions_for_debt(child, source, functions, None);
}
}
}
}
}
fn extract_python_function_info_for_debt(
node: &Node,
source: &str,
class_name: Option<&str>,
) -> Option<FunctionInfoForDebt> {
let name_node = node.child_by_field_name("name")?;
let name = get_node_text(&name_node, source);
let full_name = if let Some(cls) = class_name {
format!("{}.{}", cls, name)
} else {
name.clone()
};
let start_line = node.start_position().row as u32 + 1;
let end_line = node.end_position().row as u32 + 1;
let params = extract_python_params_for_debt(node, source);
Some(FunctionInfoForDebt {
name,
full_name,
start_line,
end_line,
params,
})
}
fn extract_python_params_for_debt(node: &Node, source: &str) -> Vec<String> {
let mut params = Vec::new();
if let Some(params_node) = node.child_by_field_name("parameters") {
let mut cursor = params_node.walk();
for child in params_node.children(&mut cursor) {
match child.kind() {
"identifier" => {
params.push(get_node_text(&child, source));
}
"typed_parameter" | "default_parameter" | "typed_default_parameter" => {
let mut inner_cursor = child.walk();
for inner_child in child.children(&mut inner_cursor) {
if inner_child.kind() == "identifier" {
params.push(get_node_text(&inner_child, source));
break;
}
}
}
"list_splat_pattern" | "dictionary_splat_pattern" => {
if let Some(name_child) = child.child(0) {
if name_child.kind() == "identifier" {
params.push(get_node_text(&name_child, source));
}
}
}
_ => {}
}
}
}
params
}
fn extract_ts_functions_for_debt(
node: Node,
source: &str,
functions: &mut Vec<FunctionInfoForDebt>,
class_name: Option<&str>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"function_declaration" | "function" => {
if let Some(info) = extract_ts_function_info_for_debt(&child, source, class_name) {
functions.push(info);
}
}
"method_definition" => {
if let Some(info) = extract_ts_function_info_for_debt(&child, source, class_name) {
functions.push(info);
}
}
"class_declaration" => {
if let Some(name_node) = child.child_by_field_name("name") {
let cls_name = get_node_text(&name_node, source);
if let Some(body) = child.child_by_field_name("body") {
extract_ts_functions_for_debt(body, source, functions, Some(&cls_name));
}
}
}
_ => {
if class_name.is_none() {
extract_ts_functions_for_debt(child, source, functions, None);
}
}
}
}
}
fn extract_ts_function_info_for_debt(
node: &Node,
source: &str,
class_name: Option<&str>,
) -> Option<FunctionInfoForDebt> {
let name = node
.child_by_field_name("name")
.map(|n| get_node_text(&n, source))?;
let full_name = if let Some(cls) = class_name {
format!("{}.{}", cls, name)
} else {
name.clone()
};
let start_line = node.start_position().row as u32 + 1;
let end_line = node.end_position().row as u32 + 1;
let params = extract_ts_params_for_debt(node, source);
Some(FunctionInfoForDebt {
name,
full_name,
start_line,
end_line,
params,
})
}
fn extract_ts_params_for_debt(node: &Node, source: &str) -> Vec<String> {
let mut params = Vec::new();
if let Some(params_node) = node.child_by_field_name("parameters") {
let mut cursor = params_node.walk();
for child in params_node.children(&mut cursor) {
match child.kind() {
"identifier" => {
params.push(get_node_text(&child, source));
}
"required_parameter" | "optional_parameter" => {
if let Some(pattern) = child.child_by_field_name("pattern") {
params.push(get_node_text(&pattern, source));
}
}
_ => {}
}
}
}
params
}
fn extract_go_functions_for_debt(
node: Node,
source: &str,
functions: &mut Vec<FunctionInfoForDebt>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"function_declaration" | "method_declaration" => {
if let Some(info) = extract_go_function_info_for_debt(&child, source) {
functions.push(info);
}
}
_ => {
extract_go_functions_for_debt(child, source, functions);
}
}
}
}
fn extract_go_function_info_for_debt(node: &Node, source: &str) -> Option<FunctionInfoForDebt> {
let name = node
.child_by_field_name("name")
.map(|n| get_node_text(&n, source))?;
let full_name = if let Some(receiver) = node.child_by_field_name("receiver") {
if let Some(type_name) = extract_go_receiver_type(&receiver, source) {
format!("{}.{}", type_name, name)
} else {
name.clone()
}
} else {
name.clone()
};
let start_line = node.start_position().row as u32 + 1;
let end_line = node.end_position().row as u32 + 1;
let params = extract_go_params_for_debt(node, source);
Some(FunctionInfoForDebt {
name,
full_name,
start_line,
end_line,
params,
})
}
fn extract_go_receiver_type(receiver: &Node, source: &str) -> Option<String> {
let mut cursor = receiver.walk();
for child in receiver.children(&mut cursor) {
if child.kind() == "parameter_declaration" {
if let Some(type_node) = child.child_by_field_name("type") {
return Some(
get_node_text(&type_node, source)
.trim_start_matches('*')
.to_string(),
);
}
}
}
None
}
fn extract_go_params_for_debt(node: &Node, source: &str) -> Vec<String> {
let mut params = Vec::new();
if let Some(params_node) = node.child_by_field_name("parameters") {
let mut cursor = params_node.walk();
for child in params_node.children(&mut cursor) {
if child.kind() == "parameter_declaration" {
let mut inner_cursor = child.walk();
for inner_child in child.children(&mut inner_cursor) {
if inner_child.kind() == "identifier" {
params.push(get_node_text(&inner_child, source));
}
}
}
}
}
params
}
fn extract_rust_functions_for_debt(
node: Node,
source: &str,
functions: &mut Vec<FunctionInfoForDebt>,
impl_name: Option<&str>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"function_item" => {
if let Some(info) = extract_rust_function_info_for_debt(&child, source, impl_name) {
functions.push(info);
}
}
"impl_item" => {
if let Some(type_node) = child.child_by_field_name("type") {
let type_name = get_node_text(&type_node, source);
if let Some(body) = child.child_by_field_name("body") {
extract_rust_functions_for_debt(body, source, functions, Some(&type_name));
}
}
}
_ => {
if impl_name.is_none() {
extract_rust_functions_for_debt(child, source, functions, None);
}
}
}
}
}
fn extract_rust_function_info_for_debt(
node: &Node,
source: &str,
impl_name: Option<&str>,
) -> Option<FunctionInfoForDebt> {
let name = node
.child_by_field_name("name")
.map(|n| get_node_text(&n, source))?;
let full_name = if let Some(impl_type) = impl_name {
format!("{}.{}", impl_type, name)
} else {
name.clone()
};
let start_line = node.start_position().row as u32 + 1;
let end_line = node.end_position().row as u32 + 1;
let params = extract_rust_params_for_debt(node, source);
Some(FunctionInfoForDebt {
name,
full_name,
start_line,
end_line,
params,
})
}
fn extract_rust_params_for_debt(node: &Node, source: &str) -> Vec<String> {
let mut params = Vec::new();
if let Some(params_node) = node.child_by_field_name("parameters") {
let mut cursor = params_node.walk();
for child in params_node.children(&mut cursor) {
match child.kind() {
"parameter" => {
if let Some(pattern) = child.child_by_field_name("pattern") {
params.push(get_node_text(&pattern, source));
}
}
"self_parameter" => {
params.push("self".to_string());
}
_ => {}
}
}
}
params
}
fn extract_java_functions_for_debt(
node: Node,
source: &str,
functions: &mut Vec<FunctionInfoForDebt>,
class_name: Option<&str>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"method_declaration" | "constructor_declaration" => {
if let Some(info) = extract_java_function_info_for_debt(&child, source, class_name)
{
functions.push(info);
}
}
"class_declaration" => {
if let Some(name_node) = child.child_by_field_name("name") {
let cls_name = get_node_text(&name_node, source);
if let Some(body) = child.child_by_field_name("body") {
extract_java_functions_for_debt(body, source, functions, Some(&cls_name));
}
}
}
_ => {
if class_name.is_none() {
extract_java_functions_for_debt(child, source, functions, None);
}
}
}
}
}
fn extract_java_function_info_for_debt(
node: &Node,
source: &str,
class_name: Option<&str>,
) -> Option<FunctionInfoForDebt> {
let name = node
.child_by_field_name("name")
.map(|n| get_node_text(&n, source))?;
let full_name = if let Some(cls) = class_name {
format!("{}.{}", cls, name)
} else {
name.clone()
};
let start_line = node.start_position().row as u32 + 1;
let end_line = node.end_position().row as u32 + 1;
let params = extract_java_params_for_debt(node, source);
Some(FunctionInfoForDebt {
name,
full_name,
start_line,
end_line,
params,
})
}
fn extract_java_params_for_debt(node: &Node, source: &str) -> Vec<String> {
let mut params = Vec::new();
if let Some(params_node) = node.child_by_field_name("parameters") {
let mut cursor = params_node.walk();
for child in params_node.children(&mut cursor) {
if child.kind() == "formal_parameter" || child.kind() == "spread_parameter" {
if let Some(name_node) = child.child_by_field_name("name") {
params.push(get_node_text(&name_node, source));
}
}
}
}
params
}
fn count_params_excluding_self(params: &[String]) -> usize {
params
.iter()
.filter(|p| *p != "self" && *p != "cls")
.count()
}
fn get_node_text(node: &Node, source: &str) -> String {
node.utf8_text(source.as_bytes()).unwrap_or("").to_string()
}
pub fn find_god_classes(source: &str, filepath: &Path, language: Language) -> Vec<DebtIssue> {
find_god_classes_inner(source, filepath, language, None)
}
fn find_god_classes_inner(
source: &str,
filepath: &Path,
language: Language,
pre_parsed: Option<&tree_sitter::Tree>,
) -> Vec<DebtIssue> {
let mut issues = Vec::new();
let owned_tree;
let tree = match pre_parsed {
Some(t) => t,
None => {
owned_tree = match parse(source, language) {
Ok(t) => t,
Err(_) => return issues,
};
&owned_tree
}
};
let root = tree.root_node();
let classes = extract_classes_for_lcom4(root, source, language);
for class_info in classes {
let non_dunder_methods: Vec<_> = class_info
.methods
.iter()
.filter(|m| !is_dunder_method(&m.name))
.collect();
let method_count = non_dunder_methods.len();
if method_count > 20 {
let lcom4 = compute_lcom4_for_class(&non_dunder_methods, source);
if lcom4 > 0.8 {
issues.push(DebtIssue {
file: filepath.to_path_buf(),
line: class_info.start_line,
element: Some(class_info.name.clone()),
rule: "god_class".to_string(),
message: format!("God class: {} methods, LCOM4={:.2}", method_count, lcom4),
category: "changeability".to_string(),
debt_minutes: 60,
});
}
}
}
issues
}
pub fn find_deep_nesting(source: &str, filepath: &Path, language: Language) -> Vec<DebtIssue> {
find_deep_nesting_inner(source, filepath, language, None)
}
fn find_deep_nesting_inner(
source: &str,
filepath: &Path,
language: Language,
pre_parsed: Option<&tree_sitter::Tree>,
) -> Vec<DebtIssue> {
let mut issues = Vec::new();
let owned_tree;
let tree = match pre_parsed {
Some(t) => t,
None => {
owned_tree = match parse(source, language) {
Ok(t) => t,
Err(_) => return issues,
};
&owned_tree
}
};
let root = tree.root_node();
let function_infos = extract_function_infos_for_debt(root, source, language);
for func_info in function_infos {
let max_depth = calculate_function_nesting_depth(source, &func_info, language);
if max_depth > 4 {
issues.push(DebtIssue {
file: filepath.to_path_buf(),
line: func_info.start_line,
element: Some(func_info.full_name.clone()),
rule: "deep_nesting".to_string(),
message: format!("Deep nesting: {} levels", max_depth),
category: "maintainability".to_string(),
debt_minutes: 15,
});
}
}
issues
}
fn calculate_function_nesting_depth(
source: &str,
func_info: &FunctionInfoForDebt,
language: Language,
) -> usize {
let tree = match parse(source, language) {
Ok(t) => t,
Err(_) => return 0,
};
let root = tree.root_node();
if let Some(func_node) = find_function_node_by_line(&root, func_info.start_line, language) {
let nesting_kinds = get_nesting_node_kinds(language);
if let Some(body) = get_function_body(&func_node, language) {
return walk_nesting_depth(&body, &nesting_kinds, 0);
}
}
0
}
fn get_nesting_node_kinds(language: Language) -> Vec<&'static str> {
match language {
Language::Python => vec![
"if_statement",
"for_statement",
"while_statement",
"try_statement",
"with_statement",
"match_statement",
],
Language::TypeScript | Language::JavaScript => vec![
"if_statement",
"for_statement",
"while_statement",
"for_in_statement",
"try_statement",
"switch_statement",
],
Language::Go => vec![
"if_statement",
"for_statement",
"switch_statement",
"select_statement",
],
Language::Rust => vec![
"if_expression",
"for_expression",
"while_expression",
"loop_expression",
"match_expression",
],
Language::Java => vec![
"if_statement",
"for_statement",
"while_statement",
"try_statement",
"switch_expression",
],
_ => vec!["if_statement", "for_statement", "while_statement"],
}
}
fn find_function_node_by_line<'a>(
node: &Node<'a>,
target_line: u32,
language: Language,
) -> Option<Node<'a>> {
let func_kinds = get_function_node_kinds(language);
let node_line = node.start_position().row as u32 + 1;
if func_kinds.contains(&node.kind()) && node_line == target_line {
return Some(*node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_function_node_by_line(&child, target_line, language) {
return Some(found);
}
}
None
}
fn get_function_node_kinds(language: Language) -> Vec<&'static str> {
get_function_node_kinds_vec(language)
}
fn get_function_body<'a>(node: &Node<'a>, language: Language) -> Option<Node<'a>> {
shared_get_function_body(*node, language)
}
fn walk_nesting_depth(node: &Node, nesting_kinds: &[&str], current_depth: usize) -> usize {
let kind = node.kind();
let new_depth = if nesting_kinds.contains(&kind) {
current_depth + 1
} else {
current_depth
};
let mut max_depth = new_depth;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let child_max = walk_nesting_depth(&child, nesting_kinds, new_depth);
max_depth = max_depth.max(child_max);
}
max_depth
}
pub fn find_high_coupling(source: &str, filepath: &Path, language: Language) -> Vec<DebtIssue> {
find_high_coupling_inner(source, filepath, language, None)
}
fn find_high_coupling_inner(
source: &str,
filepath: &Path,
language: Language,
pre_parsed: Option<&tree_sitter::Tree>,
) -> Vec<DebtIssue> {
let mut issues = Vec::new();
let owned_tree;
let tree = match pre_parsed {
Some(t) => t,
None => {
owned_tree = match parse(source, language) {
Ok(t) => t,
Err(_) => return issues,
};
&owned_tree
}
};
let imports = match crate::ast::imports::extract_imports_from_tree(tree, source, language) {
Ok(imports) => imports,
Err(_) => return issues, };
let unique_modules: HashSet<String> = imports
.iter()
.map(|i| {
let module = &i.module;
module.split('.').next().unwrap_or(module).to_string()
})
.collect();
if unique_modules.len() > 15 {
issues.push(DebtIssue {
file: filepath.to_path_buf(),
line: 1, element: None,
rule: "high_coupling".to_string(),
message: format!("File imports {} modules (> 15)", unique_modules.len()),
category: "changeability".to_string(),
debt_minutes: 20,
});
}
issues
}
pub fn find_missing_docs(source: &str, filepath: &Path, language: Language) -> Vec<DebtIssue> {
find_missing_docs_inner(source, filepath, language, None)
}
fn find_missing_docs_inner(
source: &str,
filepath: &Path,
language: Language,
pre_parsed: Option<&tree_sitter::Tree>,
) -> Vec<DebtIssue> {
let mut issues = Vec::new();
let owned_tree;
let tree = match pre_parsed {
Some(t) => t,
None => {
owned_tree = match parse(source, language) {
Ok(t) => t,
Err(_) => return issues,
};
&owned_tree
}
};
let root = tree.root_node();
if language == Language::Python {
find_python_missing_docs(&root, source, filepath, &mut issues, None);
}
issues
}
fn find_python_missing_docs(
node: &Node,
source: &str,
filepath: &Path,
issues: &mut Vec<DebtIssue>,
class_name: Option<&str>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"function_definition" => {
check_python_function_docs(&child, source, filepath, issues, class_name);
}
"decorated_definition" => {
if let Some(def) = child.child_by_field_name("definition") {
if def.kind() == "function_definition" {
check_python_function_docs(&def, source, filepath, issues, class_name);
} else if def.kind() == "class_definition" {
check_python_class_docs(&def, source, filepath, issues);
}
}
}
"class_definition" => {
check_python_class_docs(&child, source, filepath, issues);
}
_ => {
if class_name.is_none() {
find_python_missing_docs(&child, source, filepath, issues, None);
}
}
}
}
}
fn check_python_function_docs(
node: &Node,
source: &str,
filepath: &Path,
issues: &mut Vec<DebtIssue>,
class_name: Option<&str>,
) {
if let Some(name_node) = node.child_by_field_name("name") {
let name = get_node_text(&name_node, source);
if name.starts_with('_') && !name.starts_with("__") {
return;
}
if is_dunder_method(&name) {
return;
}
let has_docstring = if let Some(body) = node.child_by_field_name("body") {
has_python_docstring(&body, source)
} else {
false
};
if !has_docstring {
let full_name = if let Some(cls) = class_name {
format!("{}.{}", cls, name)
} else {
name.clone()
};
let element_type = if class_name.is_some() {
"method"
} else {
"function"
};
issues.push(DebtIssue {
file: filepath.to_path_buf(),
line: node.start_position().row as u32 + 1,
element: Some(full_name),
rule: "missing_docs".to_string(),
message: format!("Public {} '{}' lacks documentation", element_type, name),
category: "maintainability".to_string(),
debt_minutes: 10,
});
}
}
}
fn check_python_class_docs(
node: &Node,
source: &str,
filepath: &Path,
issues: &mut Vec<DebtIssue>,
) {
if let Some(name_node) = node.child_by_field_name("name") {
let name = get_node_text(&name_node, source);
if name.starts_with('_') && !name.starts_with("__") {
return;
}
let has_docstring = if let Some(body) = node.child_by_field_name("body") {
has_python_docstring(&body, source)
} else {
false
};
if !has_docstring {
issues.push(DebtIssue {
file: filepath.to_path_buf(),
line: node.start_position().row as u32 + 1,
element: Some(name.clone()),
rule: "missing_docs".to_string(),
message: format!("Public class '{}' lacks documentation", name),
category: "maintainability".to_string(),
debt_minutes: 10,
});
}
if let Some(body) = node.child_by_field_name("body") {
find_python_missing_docs(&body, source, filepath, issues, Some(&name));
}
}
}
fn has_python_docstring(body: &Node, source: &str) -> bool {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
match child.kind() {
"comment" | "pass_statement" => continue,
"expression_statement" => {
let mut inner_cursor = child.walk();
for inner in child.children(&mut inner_cursor) {
if inner.kind() == "string" {
let text = get_node_text(&inner, source);
if text.starts_with("\"\"\"")
|| text.starts_with("'''")
|| text.starts_with("r\"\"\"")
|| text.starts_with("r'''")
{
return true;
}
}
}
return false;
}
_ => {
return false;
}
}
}
false
}
pub fn compute_lcom4() -> f64 {
0.0
}
#[derive(Debug)]
struct ClassInfoForLcom4 {
name: String,
start_line: u32,
methods: Vec<MethodInfoForLcom4>,
}
#[derive(Debug)]
struct MethodInfoForLcom4 {
name: String,
start_byte: usize,
end_byte: usize,
}
struct UnionFind {
parent: Vec<usize>,
rank: Vec<usize>,
}
impl UnionFind {
fn new(n: usize) -> Self {
Self {
parent: (0..n).collect(),
rank: vec![0; n],
}
}
fn find(&mut self, x: usize) -> usize {
let mut root = x;
while self.parent[root] != root {
root = self.parent[root];
}
let mut node = x;
while self.parent[node] != root {
let next = self.parent[node];
self.parent[node] = root;
node = next;
}
root
}
fn union(&mut self, x: usize, y: usize) {
let rx = self.find(x);
let ry = self.find(y);
if rx != ry {
if self.rank[rx] < self.rank[ry] {
self.parent[rx] = ry;
} else if self.rank[rx] > self.rank[ry] {
self.parent[ry] = rx;
} else {
self.parent[ry] = rx;
self.rank[rx] += 1;
}
}
}
fn count_components(&mut self) -> usize {
let n = self.parent.len();
(0..n).map(|i| self.find(i)).collect::<HashSet<_>>().len()
}
}
fn is_dunder_method(name: &str) -> bool {
name.starts_with("__") && name.ends_with("__")
}
fn compute_lcom4_for_class(methods: &[&MethodInfoForLcom4], source: &str) -> f64 {
let n = methods.len();
if n < 2 {
return 0.0;
}
let method_fields: Vec<HashSet<String>> = methods
.iter()
.map(|m| {
let method_source = &source[m.start_byte..m.end_byte];
extract_self_accesses(method_source)
})
.collect();
let all_fields: HashSet<String> = method_fields.iter().flatten().cloned().collect();
if all_fields.is_empty() {
return 1.0;
}
let mut uf = UnionFind::new(n);
for i in 0..n {
for j in (i + 1)..n {
if !method_fields[i].is_disjoint(&method_fields[j]) {
uf.union(i, j);
}
}
}
let components = uf.count_components();
let denominator = (n - 1).max(1) as f64;
(components as f64 - 1.0) / denominator
}
fn extract_classes_for_lcom4(
root: Node,
source: &str,
language: Language,
) -> Vec<ClassInfoForLcom4> {
let mut classes = Vec::new();
if language == Language::Python {
extract_python_classes_for_lcom4(root, source, &mut classes);
}
classes
}
fn extract_python_classes_for_lcom4(
node: Node,
source: &str,
classes: &mut Vec<ClassInfoForLcom4>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"class_definition" => {
if let Some(class_info) = extract_python_class_info_for_lcom4(&child, source) {
classes.push(class_info);
}
}
"decorated_definition" => {
if let Some(def) = child.child_by_field_name("definition") {
if def.kind() == "class_definition" {
if let Some(class_info) = extract_python_class_info_for_lcom4(&def, source)
{
classes.push(class_info);
}
}
}
}
_ => {
extract_python_classes_for_lcom4(child, source, classes);
}
}
}
}
fn extract_python_class_info_for_lcom4(node: &Node, source: &str) -> Option<ClassInfoForLcom4> {
let name_node = node.child_by_field_name("name")?;
let name = get_node_text(&name_node, source);
let start_line = node.start_position().row as u32 + 1;
let body = node.child_by_field_name("body")?;
let methods = extract_python_methods_for_lcom4(&body, source);
Some(ClassInfoForLcom4 {
name,
start_line,
methods,
})
}
fn extract_python_methods_for_lcom4(body: &Node, source: &str) -> Vec<MethodInfoForLcom4> {
let mut methods = Vec::new();
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
match child.kind() {
"function_definition" => {
if let Some(method_info) = extract_python_method_info_for_lcom4(&child, source) {
methods.push(method_info);
}
}
"decorated_definition" => {
if let Some(def) = child.child_by_field_name("definition") {
if def.kind() == "function_definition" {
if let Some(method_info) =
extract_python_method_info_for_lcom4(&def, source)
{
methods.push(method_info);
}
}
}
}
_ => {}
}
}
methods
}
fn extract_python_method_info_for_lcom4(node: &Node, source: &str) -> Option<MethodInfoForLcom4> {
let name_node = node.child_by_field_name("name")?;
let name = get_node_text(&name_node, source);
Some(MethodInfoForLcom4 {
name,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
})
}
pub fn analyze_file(
filepath: &Path,
category_filter: Option<&str>,
language_override: Option<Language>,
) -> TldrResult<(Vec<DebtIssue>, usize)> {
let source = match std::fs::read_to_string(filepath) {
Ok(s) => s,
Err(_) => return Ok((vec![], 0)), };
let lang = language_override.or_else(|| Language::from_path(filepath));
let lang = match lang {
Some(l) => l,
None => return Ok((vec![], 0)), };
let loc = count_loc(&source, lang);
let tree = parse(&source, lang).ok();
let tree_ref = tree.as_ref();
let mut issues = Vec::new();
issues.extend(find_todo_comments_inner(&source, filepath, lang, tree_ref));
issues.extend(find_complexity_issues_inner(
&source, filepath, lang, tree_ref,
));
issues.extend(find_god_classes_inner(&source, filepath, lang, tree_ref));
issues.extend(find_deep_nesting_inner(&source, filepath, lang, tree_ref));
issues.extend(find_high_coupling_inner(&source, filepath, lang, tree_ref));
issues.extend(find_missing_docs_inner(&source, filepath, lang, tree_ref));
if let Some(category) = category_filter {
issues.retain(|issue| issue.category == category);
}
Ok((issues, loc))
}
const SKIP_DIRS: &[&str] = &[
"__pycache__",
".git",
"node_modules",
".venv",
"venv",
"target",
"build",
"dist",
".tox",
".mypy_cache",
];
pub fn analyze_debt(options: DebtOptions) -> TldrResult<DebtReport> {
use std::collections::HashMap;
use walkdir::WalkDir;
let path = &options.path;
if !path.exists() {
return Err(crate::TldrError::PathNotFound(path.clone()));
}
let mut all_issues: Vec<DebtIssue> = Vec::new();
let mut total_loc: usize = 0;
let mut file_debts: HashMap<PathBuf, FileDebt> = HashMap::new();
if path.is_file() {
let (issues, loc) =
analyze_file(path, options.category_filter.as_deref(), options.language)?;
total_loc += loc;
if !issues.is_empty() {
let total_minutes: u32 = issues.iter().map(|i| i.debt_minutes).sum();
file_debts.insert(
path.clone(),
FileDebt {
file: path.clone(),
total_minutes,
issue_count: issues.len(),
issues: issues.clone(),
},
);
all_issues.extend(issues);
}
} else if path.is_dir() {
const MAX_FILE_SIZE: u64 = 500 * 1024;
let file_paths: Vec<PathBuf> = WalkDir::new(path)
.follow_links(false) .into_iter()
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
if name.starts_with('.') && entry.depth() > 0 {
return false;
}
if entry.file_type().is_dir() && SKIP_DIRS.contains(&name.as_ref()) {
return false;
}
true
})
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter(|e| Language::from_path(e.path()).is_some() || options.language.is_some())
.filter(|e| {
e.metadata()
.map(|m| m.len() <= MAX_FILE_SIZE)
.unwrap_or(true)
})
.map(|e| e.path().to_path_buf())
.collect();
let category_filter = options.category_filter.as_deref();
let language_opt = options.language;
let results: Vec<(PathBuf, Vec<DebtIssue>, usize)> = file_paths
.par_iter()
.filter_map(
|fpath| match analyze_file(fpath, category_filter, language_opt) {
Ok((issues, loc)) => Some((fpath.clone(), issues, loc)),
Err(_) => None,
},
)
.collect();
for (fpath, issues, loc) in results {
total_loc += loc;
if !issues.is_empty() {
let total_minutes: u32 = issues.iter().map(|i| i.debt_minutes).sum();
file_debts.insert(
fpath.clone(),
FileDebt {
file: fpath,
total_minutes,
issue_count: issues.len(),
issues: issues.clone(),
},
);
all_issues.extend(issues);
}
}
}
if options.min_debt > 0 {
all_issues.retain(|i| i.debt_minutes >= options.min_debt);
}
let total_minutes: u32 = all_issues.iter().map(|i| i.debt_minutes).sum();
let total_hours = (total_minutes as f64 / 60.0 * 100.0).round() / 100.0;
let debt_ratio = if total_loc > 0 {
((total_minutes as f64 / total_loc as f64) * 1000.0).round() / 1000.0
} else {
0.0
};
let debt_density = (debt_ratio * 1000.0 * 100.0).round() / 100.0;
let total_cost = options
.hourly_rate
.map(|rate| (total_hours * rate * 100.0).round() / 100.0);
let mut by_category: BTreeMap<String, u32> = BTreeMap::new();
for issue in &all_issues {
*by_category.entry(issue.category.clone()).or_default() += issue.debt_minutes;
}
let mut by_rule: BTreeMap<String, u32> = BTreeMap::new();
for issue in &all_issues {
*by_rule.entry(issue.rule.clone()).or_default() += issue.debt_minutes;
}
all_issues.sort_by(|a, b| b.debt_minutes.cmp(&a.debt_minutes));
let mut sorted_files: Vec<_> = file_debts.values().cloned().collect();
sorted_files.sort_by(|a, b| b.total_minutes.cmp(&a.total_minutes));
let top_files: Vec<_> = sorted_files.into_iter().take(options.top_k).collect();
Ok(DebtReport {
issues: all_issues,
top_files,
summary: DebtSummary {
total_minutes,
total_hours,
total_cost,
debt_ratio,
debt_density,
by_category,
by_rule,
},
})
}