mod lang;
use crate::review::diff::ChangedFile;
use crate::review::signals::content::ReviewSource;
use crate::scan::language::detect_language;
use lang::{function_name, is_call_node, is_control_flow_node, is_else_if, is_loop_node};
use serde::Serialize;
use std::collections::HashMap;
use tree_sitter::Node;
const FUNCTION_GREW_THRESHOLD: usize = 50;
const COMPLEXITY_FLOOR: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum AlgorithmicKind {
ComplexityIncreased,
NestedLoopIntroduced,
FunctionGrew,
RecursionIntroduced,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AlgorithmicSignal {
pub kind: AlgorithmicKind,
pub path: String,
pub line: usize,
pub detail: String,
}
struct FnMetrics {
name: String,
start_line: usize,
end_line: usize,
max_nesting: usize,
has_nested_loop: bool,
line_span: usize,
is_recursive: bool,
}
pub fn detect_algorithmic(
file: &ChangedFile,
pre_source: Option<&ReviewSource>,
post_source: Option<&ReviewSource>,
) -> Vec<AlgorithmicSignal> {
let mut signals = Vec::new();
if crate::audits::context::classify::helpers::is_test_file(&file.path) {
return signals;
}
let Some(post) = post_source else {
return signals;
};
let Some(language) = detect_language(&file.path) else {
return signals;
};
let post_parsed = post.parsed();
let Some(post_tree) = post_parsed.tree() else {
return signals;
};
let post_fns = collect_functions(post_tree.root_node(), post.content(), language);
let pre_fns: Vec<FnMetrics> = pre_source
.map(|src| {
let parsed = src.parsed();
parsed
.tree()
.map(|tree| collect_functions(tree.root_node(), src.content(), language))
.unwrap_or_default()
})
.unwrap_or_default();
let mut occurrence: HashMap<&str, usize> = HashMap::new();
for post_fn in &post_fns {
let idx = {
let counter = occurrence.entry(post_fn.name.as_str()).or_insert(0);
let current = *counter;
*counter += 1;
current
};
if !function_overlaps_change(post_fn, file) {
continue;
}
let pre_fn = pre_fns
.iter()
.filter(|candidate| candidate.name == post_fn.name)
.nth(idx);
emit_signals(file, post_fn, pre_fn, &mut signals);
}
signals
}
fn function_overlaps_change(func: &FnMetrics, file: &ChangedFile) -> bool {
(func.start_line..=func.end_line).any(|line| file.contains_line(line))
}
fn emit_signals(
file: &ChangedFile,
post: &FnMetrics,
pre: Option<&FnMetrics>,
signals: &mut Vec<AlgorithmicSignal>,
) {
let path = file.path_string();
let mut push = |kind, detail| {
signals.push(AlgorithmicSignal {
kind,
path: path.clone(),
line: post.start_line,
detail,
});
};
let (pre_nesting, pre_nested_loop, pre_recursive, pre_span) = match pre {
Some(pre) => (
pre.max_nesting,
pre.has_nested_loop,
pre.is_recursive,
pre.line_span,
),
None => (0, false, false, 0),
};
if post.max_nesting > pre_nesting && post.max_nesting >= COMPLEXITY_FLOOR {
push(
AlgorithmicKind::ComplexityIncreased,
format!(
"fn `{}` control-flow nesting {pre_nesting} → {}",
post.name, post.max_nesting
),
);
}
if post.has_nested_loop && !pre_nested_loop {
push(
AlgorithmicKind::NestedLoopIntroduced,
format!(
"fn `{}` introduces a nested loop (potential O(n^2))",
post.name
),
);
}
if post.is_recursive && !pre_recursive {
push(
AlgorithmicKind::RecursionIntroduced,
format!("fn `{}` is now recursive", post.name),
);
}
if post.line_span > FUNCTION_GREW_THRESHOLD && post.line_span > pre_span {
push(
AlgorithmicKind::FunctionGrew,
format!("fn `{}` grew to {} lines", post.name, post.line_span),
);
}
}
fn collect_functions(root: Node<'_>, content: &str, language: &str) -> Vec<FnMetrics> {
let mut out = Vec::new();
collect_visit(root, content, language, &mut out);
out
}
fn collect_visit(node: Node<'_>, content: &str, language: &str, out: &mut Vec<FnMetrics>) {
if let Some(name) = function_name(node, language, content) {
let start_line = node.start_position().row + 1;
let end_line = node.end_position().row + 1;
out.push(FnMetrics {
max_nesting: subtree_max_nesting(node, language, 0),
has_nested_loop: subtree_has_nested_loop(node, language, false),
line_span: end_line.saturating_sub(start_line) + 1,
is_recursive: subtree_calls_function(node, language, content, &name, true),
name,
start_line,
end_line,
});
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_visit(child, content, language, out);
}
}
fn subtree_max_nesting(node: Node<'_>, language: &str, depth: usize) -> usize {
let is_cf = is_control_flow_node(node.kind(), language) && !is_else_if(node, language);
let here = if is_cf { depth + 1 } else { depth };
let mut max = here;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
max = max.max(subtree_max_nesting(child, language, here));
}
max
}
fn subtree_has_nested_loop(node: Node<'_>, language: &str, inside_loop: bool) -> bool {
let is_loop = is_loop_node(node.kind(), language);
if is_loop && inside_loop {
return true;
}
let next = inside_loop || is_loop;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if subtree_has_nested_loop(child, language, next) {
return true;
}
}
false
}
fn subtree_calls_function(
node: Node<'_>,
language: &str,
content: &str,
function: &str,
is_root: bool,
) -> bool {
if !is_root && function_name(node, language, content).is_some() {
return false;
}
if is_call_node(node, language)
&& call_target(node, content).is_some_and(|target| {
target == function
|| target == format!("self.{function}")
|| target == format!("this.{function}")
|| target == format!("Self::{function}")
})
{
return true;
}
let mut cursor = node.walk();
node.children(&mut cursor)
.any(|child| subtree_calls_function(child, language, content, function, false))
}
fn call_target<'a>(node: Node<'a>, content: &'a str) -> Option<&'a str> {
let target = node
.child_by_field_name("function")
.or_else(|| node.child_by_field_name("name"))
.or_else(|| node.named_child(0))?;
target.utf8_text(content.as_bytes()).ok().map(str::trim)
}