use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use tree_sitter::{Node, Parser};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Lang {
Python,
Php,
Ruby,
Ts, Rust,
}
impl Lang {
pub(crate) fn from_path(path: &Path) -> Option<Lang> {
match path.extension().and_then(|e| e.to_str())?.to_ascii_lowercase().as_str() {
"py" | "pyw" => Some(Lang::Python),
"php" => Some(Lang::Php),
"rb" => Some(Lang::Ruby),
"ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "mts" | "cts" => Some(Lang::Ts),
"rs" => Some(Lang::Rust),
_ => None,
}
}
fn tree_sitter_language(self) -> tree_sitter::Language {
match self {
Lang::Python => tree_sitter_python::LANGUAGE.into(),
Lang::Php => tree_sitter_php::LANGUAGE_PHP.into(),
Lang::Ruby => tree_sitter_ruby::LANGUAGE.into(),
Lang::Ts => tree_sitter_typescript::LANGUAGE_TSX.into(),
Lang::Rust => tree_sitter_rust::LANGUAGE.into(),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct FunctionInfo {
pub name: String,
pub file: String,
pub line: usize,
pub complexity: u32,
pub loc: usize,
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct FileMetrics {
pub path: String,
pub loc: usize,
pub complexity: u32, pub avg_complexity: f64, pub functions: usize,
pub maintainability: f64, pub halstead_volume: f64, pub has_tests: bool,
pub dep_count: usize,
pub coupling_efferent: usize,
pub coupling_afferent: usize,
pub instability: f64,
pub parse_health: f64, }
fn round_dp(value: f64, dp: i32) -> f64 {
let m = 10f64.powi(dp);
(value * m).round() / m
}
fn maintainability_index(volume: f64, avg_cc: f64, loc: usize) -> f64 {
if loc == 0 {
return 100.0;
}
let v = volume.max(1.0);
let mi = 171.0 - 5.2 * v.ln() - 0.23 * avg_cc - 16.2 * (loc as f64).ln();
(mi * 100.0 / 171.0).clamp(0.0, 100.0)
}
fn is_code_line(line: &str, lang: Lang) -> bool {
let t = line.trim();
if t.is_empty() {
return false;
}
match lang {
Lang::Python | Lang::Ruby => !t.starts_with('#'),
Lang::Php => !(t.starts_with("//") || t.starts_with('#') || t.starts_with("/*") || t.starts_with('*')),
Lang::Ts | Lang::Rust => !(t.starts_with("//") || t.starts_with("/*") || t.starts_with('*')),
}
}
fn count_loc(source: &str, lang: Lang) -> usize {
source.lines().filter(|l| is_code_line(l, lang)).count()
}
#[derive(Default)]
struct Halstead {
total: usize,
unique: HashSet<String>,
}
impl Halstead {
fn add(&mut self, key: impl Into<String>) {
self.total += 1;
self.unique.insert(key.into());
}
}
fn operator_token<'a>(node: Node, src: &'a [u8]) -> Option<&'a str> {
let mut c = node.walk();
for child in node.children(&mut c) {
if !child.is_named() {
return child.utf8_text(src).ok();
}
}
None
}
fn volume(n1: usize, n2: usize, big_n1: usize, big_n2: usize) -> f64 {
let vocabulary = n1 + n2;
let length = big_n1 + big_n2;
if vocabulary > 0 {
(length as f64) * (vocabulary as f64).log2()
} else {
0.0
}
}
fn py_binop_name(tok: &str) -> &'static str {
match tok {
"+" => "Add", "-" => "Sub", "*" => "Mult", "/" => "Div", "%" => "Mod",
"**" => "Pow", "//" => "FloorDiv", "<<" => "LShift", ">>" => "RShift",
"&" => "BitAnd", "|" => "BitOr", "^" => "BitXor", "@" => "MatMult",
_ => "BinOp",
}
}
fn py_unary_name(tok: &str) -> &'static str {
match tok {
"-" => "USub", "+" => "UAdd", "~" => "Invert", _ => "UnaryOp",
}
}
fn py_aug_name(tok: &str) -> &'static str {
match tok {
"+=" => "AugAdd", "-=" => "AugSub", "*=" => "AugMult", "/=" => "AugDiv",
"%=" => "AugMod", "**=" => "AugPow", "//=" => "AugFloorDiv",
"<<=" => "AugLShift", ">>=" => "AugRShift", "&=" => "AugBitAnd",
"|=" => "AugBitOr", "^=" => "AugBitXor", "@=" => "AugMatMult",
_ => "AugAssign",
}
}
fn py_compare_ops(node: Node, src: &[u8]) -> Vec<&'static str> {
let mut toks: Vec<String> = Vec::new();
let mut c = node.walk();
for child in node.children(&mut c) {
if !child.is_named() {
toks.push(child.utf8_text(src).unwrap_or("").to_string());
}
}
let mut out = Vec::new();
let mut i = 0;
while i < toks.len() {
let t = toks[i].as_str();
match t {
"is not" => out.push("IsNot"),
"not in" => out.push("NotIn"),
"is" => {
if toks.get(i + 1).map(String::as_str) == Some("not") {
out.push("IsNot");
i += 1;
} else {
out.push("Is");
}
}
"not" => {
if toks.get(i + 1).map(String::as_str) == Some("in") {
out.push("NotIn");
i += 1;
} else {
out.push("Not");
}
}
"<" => out.push("Lt"),
"<=" => out.push("LtE"),
">" => out.push("Gt"),
">=" => out.push("GtE"),
"==" => out.push("Eq"),
"!=" | "<>" => out.push("NotEq"),
"in" => out.push("In"),
_ => {}
}
i += 1;
}
out
}
fn py_identifier_is_operand(parent_kind: Option<&str>, field: Option<&str>) -> bool {
match parent_kind {
Some("function_definition") | Some("class_definition") => field != Some("name"),
Some("attribute") => field != Some("attribute"),
Some("keyword_argument") => field != Some("name"),
Some("parameters") | Some("lambda_parameters") => false,
Some("default_parameter") => field != Some("name"),
Some("typed_parameter") => false,
Some("typed_default_parameter") => field == Some("value"),
Some("list_splat_pattern") | Some("dictionary_splat_pattern") => false,
Some("import_statement") | Some("import_from_statement") | Some("dotted_name")
| Some("aliased_import") => false,
Some("global_statement") | Some("nonlocal_statement") => false,
Some("as_pattern_target") => false,
_ => true,
}
}
fn py_halstead(
node: Node,
parent_kind: Option<&str>,
field: Option<&str>,
parent_bool_op: Option<&str>,
src: &[u8],
operators: &mut Halstead,
operands: &mut Halstead,
) {
let kind = node.kind();
let mut this_bool_op: Option<&str> = None;
match kind {
"binary_operator" => {
if let Some(t) = operator_token(node, src) {
operators.add(py_binop_name(t));
}
}
"unary_operator" => {
if let Some(t) = operator_token(node, src) {
operators.add(py_unary_name(t));
}
}
"not_operator" => operators.add("Not"),
"boolean_operator" => {
let tok = operator_token(node, src).unwrap_or("");
this_bool_op = Some(if tok == "or" { "or" } else { "and" });
if parent_bool_op != this_bool_op {
operators.add(if tok == "or" { "Or" } else { "And" });
}
}
"comparison_operator" => {
for name in py_compare_ops(node, src) {
operators.add(name);
}
}
"augmented_assignment" => {
if let Some(t) = operator_token(node, src) {
operators.add(py_aug_name(t));
}
}
"identifier" => {
if py_identifier_is_operand(parent_kind, field) {
operands.add(node.utf8_text(src).unwrap_or("").to_string());
}
}
"string_content" => {
let t = node.utf8_text(src).unwrap_or("");
operands.add(t.chars().take(50).collect::<String>());
}
"integer" | "float" => operands.add(node.utf8_text(src).unwrap_or("").to_string()),
"true" => operands.add("True"),
"false" => operands.add("False"),
"none" => operands.add("None"),
_ => {}
}
let mut c = node.walk();
if c.goto_first_child() {
loop {
let child = c.node();
let child_field = c.field_name();
py_halstead(child, Some(kind), child_field, this_bool_op, src, operators, operands);
if !c.goto_next_sibling() {
break;
}
}
}
}
const GENERIC_OPERATOR_TOKENS: &[&str] = &[
"+", "-", "*", "/", "%", "**", "//", "++", "--", "==", "===", "!=", "!==", "<>",
"<", ">", "<=", ">=", "<=>", "&&", "||", "!", "and", "or", "not", "xor", "&", "|",
"^", "~", "<<", ">>", "=", "+=", "-=", "*=", "/=", "%=", "**=", "//=", "&=", "|=",
"^=", "<<=", ">>=", ".=", "??", "?", "=~", "->", "=>", "::",
];
fn rust_is_operand_leaf(kind: &str) -> bool {
matches!(
kind,
"integer_literal"
| "float_literal"
| "boolean_literal"
| "char_literal"
| "primitive_type"
| "field_identifier"
| "self"
| "super"
| "crate"
)
}
fn generic_is_operand_leaf(kind: &str, lang: Lang) -> bool {
if lang == Lang::Rust && rust_is_operand_leaf(kind) {
return true;
}
matches!(
kind,
"identifier"
| "name"
| "property_identifier"
| "shorthand_property_identifier"
| "type_identifier"
| "constant"
| "instance_variable"
| "class_variable"
| "global_variable"
| "simple_symbol"
| "integer"
| "float"
| "number"
| "string_content"
| "string_fragment"
| "true"
| "false"
| "null"
| "nil"
| "none"
)
}
fn generic_halstead(
node: Node,
src: &[u8],
lang: Lang,
operators: &mut Halstead,
operands: &mut Halstead,
) {
let kind = node.kind();
if node.is_named() {
if node.named_child_count() == 0 && generic_is_operand_leaf(kind, lang) {
operands.add(node.utf8_text(src).unwrap_or("").chars().take(50).collect::<String>());
}
} else if GENERIC_OPERATOR_TOKENS.contains(&kind) {
operators.add(kind);
}
let mut c = node.walk();
if c.goto_first_child() {
loop {
generic_halstead(c.node(), src, lang, operators, operands);
if !c.goto_next_sibling() {
break;
}
}
}
}
fn file_volume(root: Node, src: &[u8], lang: Lang) -> f64 {
let mut operators = Halstead::default();
let mut operands = Halstead::default();
match lang {
Lang::Python => py_halstead(root, None, None, None, src, &mut operators, &mut operands),
_ => generic_halstead(root, src, lang, &mut operators, &mut operands),
}
volume(operators.unique.len(), operands.unique.len(), operators.total, operands.total)
}
fn parse_health(root: Node, total_lines: usize) -> f64 {
if total_lines == 0 {
return 1.0;
}
let mut bad: HashSet<usize> = HashSet::new();
let mut stack = vec![root];
while let Some(n) = stack.pop() {
if n.is_error() || n.is_missing() {
for row in n.start_position().row..=n.end_position().row {
bad.insert(row);
}
if n.is_error() {
continue;
}
}
let mut c = n.walk();
for child in n.children(&mut c) {
stack.push(child);
}
}
let clean = total_lines.saturating_sub(bad.len());
(clean as f64 / total_lines as f64).clamp(0.0, 1.0)
}
const MIN_PARSE_HEALTH: f64 = 0.95;
const MAX_AST_DEPTH: usize = 800;
fn depth_exceeds(root: Node, limit: usize) -> bool {
let mut stack = vec![(root, 0usize)];
while let Some((node, depth)) = stack.pop() {
if depth > limit {
return true;
}
let mut c = node.walk();
for child in node.children(&mut c) {
stack.push((child, depth + 1));
}
}
false
}
fn is_boolean_binary(node: Node, src: &[u8]) -> bool {
matches!(operator_token(node, src), Some("&&") | Some("||") | Some("and") | Some("or"))
}
fn is_decision(node: Node, lang: Lang, src: &[u8]) -> u32 {
if !node.is_named() {
return 0;
}
let k = node.kind();
match lang {
Lang::Python => matches!(
k,
"if_statement"
| "elif_clause"
| "for_statement"
| "while_statement"
| "except_clause"
| "assert_statement"
| "conditional_expression"
| "boolean_operator"
| "for_in_clause"
| "if_clause"
) as u32,
Lang::Php => {
if matches!(
k,
"if_statement"
| "else_if_clause"
| "for_statement"
| "foreach_statement"
| "while_statement"
| "do_statement"
| "case_statement"
| "catch_clause"
| "conditional_expression"
) {
1
} else {
(k == "binary_expression" && is_boolean_binary(node, src)) as u32
}
}
Lang::Ruby => {
if matches!(
k,
"if" | "elsif" | "unless" | "while" | "until" | "for" | "when" | "rescue"
| "conditional" | "if_modifier" | "unless_modifier" | "while_modifier"
| "until_modifier"
) {
1
} else {
(k == "binary" && is_boolean_binary(node, src)) as u32
}
}
Lang::Ts => {
if matches!(
k,
"if_statement"
| "for_statement"
| "for_in_statement"
| "while_statement"
| "do_statement"
| "switch_case"
| "catch_clause"
| "ternary_expression"
) {
1
} else {
(k == "binary_expression" && is_boolean_binary(node, src)) as u32
}
}
Lang::Rust => {
if matches!(
k,
"if_expression"
| "while_expression"
| "loop_expression"
| "for_expression"
| "try_expression"
) {
1
} else if k == "match_arm" {
(!is_rust_wildcard_arm(node, src)) as u32
} else {
(k == "binary_expression" && is_boolean_binary(node, src)) as u32
}
}
}
}
fn is_rust_wildcard_arm(node: Node, src: &[u8]) -> bool {
node.child_by_field_name("pattern")
.and_then(|p| p.utf8_text(src).ok())
.map(|t| t.trim() == "_")
.unwrap_or(false)
}
fn is_scope_boundary(kind: &str, lang: Lang) -> bool {
if is_function_node(kind, lang) {
return true;
}
match lang {
Lang::Python => kind == "class_definition",
Lang::Php => matches!(kind, "class_declaration" | "interface_declaration"
| "trait_declaration" | "enum_declaration"),
Lang::Ruby => matches!(kind, "class" | "module"),
Lang::Ts => matches!(kind, "class_declaration" | "class"),
Lang::Rust => is_class_node(kind, Lang::Rust),
}
}
fn count_own_decisions(node: Node, lang: Lang, src: &[u8]) -> u32 {
let mut total = is_decision(node, lang, src);
let mut c = node.walk();
if c.goto_first_child() {
loop {
let child = c.node();
if !is_scope_boundary(child.kind(), lang) {
total += count_own_decisions(child, lang, src);
}
if !c.goto_next_sibling() {
break;
}
}
}
total
}
fn is_function_node(kind: &str, lang: Lang) -> bool {
match lang {
Lang::Python => kind == "function_definition",
Lang::Php => matches!(kind, "function_definition" | "method_declaration"),
Lang::Ruby => matches!(kind, "method" | "singleton_method"),
Lang::Ts => matches!(
kind,
"function_declaration"
| "generator_function_declaration"
| "method_definition"
| "function_expression"
| "arrow_function"
),
Lang::Rust => matches!(kind, "function_item" | "function_signature_item"),
}
}
fn is_class_node(kind: &str, lang: Lang) -> bool {
match lang {
Lang::Python => kind == "class_definition",
Lang::Php => matches!(kind, "class_declaration" | "trait_declaration" | "interface_declaration"),
Lang::Ruby => matches!(kind, "class" | "module"),
Lang::Ts => matches!(kind, "class_declaration" | "class"),
Lang::Rust => matches!(
kind,
"impl_item" | "trait_item" | "struct_item" | "enum_item" | "union_item"
),
}
}
fn is_type_decl_node(kind: &str, lang: Lang) -> bool {
if is_class_node(kind, lang) {
return true;
}
match lang {
Lang::Ts => matches!(kind, "interface_declaration" | "type_alias_declaration" | "enum_declaration"),
Lang::Rust => kind == "type_item",
_ => false,
}
}
fn node_name(node: Node, src: &[u8]) -> Option<String> {
node.child_by_field_name("name")
.or_else(|| node.child_by_field_name("type"))
.and_then(|n| n.utf8_text(src).ok())
.map(|s| s.to_string())
}
fn outer_class_name(node: Node, lang: Lang, src: &[u8]) -> Option<String> {
let mut found: Option<String> = None;
let mut cur = node.parent();
while let Some(p) = cur {
if is_class_node(p.kind(), lang) {
if let Some(n) = node_name(p, src) {
found = Some(n);
}
}
cur = p.parent();
}
found
}
fn function_display_name(node: Node, lang: Lang, src: &[u8]) -> String {
let base = node_name(node, src).unwrap_or_else(|| {
if let Some(parent) = node.parent() {
if matches!(parent.kind(), "variable_declarator" | "pair" | "assignment_expression") {
if let Some(n) = parent
.child_by_field_name("name")
.or_else(|| parent.child_by_field_name("key"))
.or_else(|| parent.child_by_field_name("left"))
{
if let Ok(t) = n.utf8_text(src) {
return t.to_string();
}
}
}
}
"(anonymous)".to_string()
});
match outer_class_name(node, lang, src) {
Some(cls) => format!("{cls}.{base}"),
None => base,
}
}
fn collect_functions<'a>(node: Node<'a>, lang: Lang, src: &[u8], out: &mut Vec<(Node<'a>, u32)>) {
if is_function_node(node.kind(), lang) {
out.push((node, count_own_decisions(node, lang, src) + 1));
}
let mut c = node.walk();
if c.goto_first_child() {
loop {
collect_functions(c.node(), lang, src, out);
if !c.goto_next_sibling() {
break;
}
}
}
}
fn is_import_node(kind: &str, lang: Lang) -> bool {
match lang {
Lang::Python => matches!(kind, "import_statement" | "import_from_statement"),
Lang::Php => matches!(kind, "namespace_use_declaration" | "require_once_expression"
| "require_expression" | "include_expression" | "include_once_expression"),
Lang::Ruby => false, Lang::Ts => matches!(kind, "import_statement"),
Lang::Rust => matches!(kind, "use_declaration" | "mod_item"),
}
}
fn unquote(raw: &str) -> String {
let t = raw.trim();
let t = t.strip_prefix("b").unwrap_or(t); for q in ['"', '\'', '`'] {
if let Some(inner) = t.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
return inner.to_string();
}
}
t.to_string()
}
fn first_string_literal(node: Node, src: &[u8]) -> Option<String> {
let mut stack = vec![node];
while let Some(n) = stack.pop() {
if n.kind().contains("string") && n.child_count() == 0 || n.kind() == "string" {
if let Ok(t) = n.utf8_text(src) {
let u = unquote(t);
if !u.is_empty() {
return Some(u);
}
}
}
let mut c = n.walk();
for child in n.children(&mut c) {
stack.push(child);
}
}
None
}
fn extract_import_specs(root: Node, lang: Lang, src: &[u8]) -> Vec<String> {
let mut specs: Vec<String> = Vec::new();
let mut stack = vec![root];
while let Some(node) = stack.pop() {
let kind = node.kind();
match lang {
Lang::Python if kind == "import_from_statement" => {
if let Some(m) = node.child_by_field_name("module_name") {
if let Ok(t) = m.utf8_text(src) {
specs.push(t.trim().to_string());
}
} else if let Ok(t) = node.utf8_text(src) {
if let Some(rest) = t.trim().strip_prefix("from ") {
let dots: String =
rest.chars().take_while(|c| *c == '.').collect();
if !dots.is_empty() {
specs.push(dots);
}
}
}
}
Lang::Python if kind == "import_statement" => {
let mut c = node.walk();
for child in node.children(&mut c) {
match child.kind() {
"dotted_name" => {
if let Ok(t) = child.utf8_text(src) {
specs.push(t.trim().to_string());
}
}
"aliased_import" => {
if let Some(n) = child.child_by_field_name("name") {
if let Ok(t) = n.utf8_text(src) {
specs.push(t.trim().to_string());
}
}
}
_ => {}
}
}
}
Lang::Ts if kind == "import_statement" => {
if let Some(s) = node.child_by_field_name("source") {
if let Ok(t) = s.utf8_text(src) {
specs.push(unquote(t));
}
} else if let Some(s) = first_string_literal(node, src) {
specs.push(s);
}
}
Lang::Php
if matches!(kind, "namespace_use_clause" | "namespace_use_group_clause") =>
{
let mut c = node.walk();
for child in node.children(&mut c) {
if child.kind().contains("qualified_name") || child.kind() == "name" {
if let Ok(t) = child.utf8_text(src) {
let t = t.trim();
if !t.is_empty() {
specs.push(t.to_string());
}
break; }
}
}
}
Lang::Php
if matches!(
kind,
"require_once_expression"
| "require_expression"
| "include_expression"
| "include_once_expression"
) =>
{
if let Some(s) = first_string_literal(node, src) {
specs.push(s);
}
}
Lang::Php if kind.contains("qualified_name") => {
if let Ok(t) = node.utf8_text(src) {
let t = t.trim();
if t.contains('\\') && t.len() > 1 {
specs.push(t.to_string());
}
}
}
Lang::Rust if kind == "use_declaration" => {
if let Some(arg) = node.child_by_field_name("argument") {
if let Ok(t) = arg.utf8_text(src) {
let head = t.split('{').next().unwrap_or(t).trim().trim_end_matches("::");
if !head.is_empty() {
specs.push(head.to_string());
}
}
}
}
Lang::Rust if kind == "mod_item" && node.child_by_field_name("body").is_none() => {
if let Some(n) = node.child_by_field_name("name") {
if let Ok(t) = n.utf8_text(src) {
specs.push(format!("mod:{}", t.trim()));
}
}
}
Lang::Ruby if kind == "call" => {
if let Some(m) = node
.child_by_field_name("method")
.and_then(|n| n.utf8_text(src).ok())
{
if matches!(m, "require" | "require_relative" | "load" | "autoload") {
if let Some(args) = node.child_by_field_name("arguments") {
if let Some(s) = first_string_literal(args, src) {
specs.push(s);
}
}
}
}
}
_ => {}
}
let mut c = node.walk();
for child in node.children(&mut c) {
stack.push(child);
}
}
specs.sort();
specs.dedup();
specs
}
fn normalise_path(raw: &str) -> String {
let unified = raw.replace('\\', "/");
let mut out: Vec<&str> = Vec::new();
for seg in unified.split('/') {
match seg {
"" | "." => {}
".." => {
out.pop();
}
s => out.push(s),
}
}
out.join("/")
}
fn parent_dir(rel: &str) -> String {
match rel.rfind('/') {
Some(i) => rel[..i].to_string(),
None => String::new(),
}
}
fn resolve_import(
spec: &str,
from_rel: &str,
lang: Lang,
paths: &HashSet<String>,
root_pkg: Option<&str>,
) -> Option<String> {
let dir = parent_dir(from_rel);
let mut candidates: Vec<String> = Vec::new();
let mut push = |c: String| candidates.push(normalise_path(&c));
match lang {
Lang::Python => {
let dots = spec.chars().take_while(|c| *c == '.').count();
let tail = spec.trim_start_matches('.').replace('.', "/");
if dots > 0 {
let mut base = dir.clone();
for _ in 1..dots {
base = parent_dir(&base);
}
let joined = if tail.is_empty() {
base.clone()
} else if base.is_empty() {
tail.clone()
} else {
format!("{base}/{tail}")
};
push(format!("{joined}.py"));
push(format!("{joined}/__init__.py"));
} else {
push(format!("{tail}.py"));
push(format!("{tail}/__init__.py"));
if let Some(pkg) = root_pkg {
if let Some(rest) = tail.strip_prefix(&format!("{pkg}/")) {
push(format!("{rest}.py"));
push(format!("{rest}/__init__.py"));
}
}
}
}
Lang::Ts => {
if !(spec.starts_with('.') || spec.starts_with('/')) {
return None;
}
let joined = if dir.is_empty() {
spec.to_string()
} else {
format!("{dir}/{spec}")
};
let base = normalise_path(&joined);
let stem = base
.strip_suffix(".js")
.or_else(|| base.strip_suffix(".mjs"))
.unwrap_or(&base)
.to_string();
for ext in ["ts", "tsx", "js", "mjs", "jsx"] {
push(format!("{stem}.{ext}"));
push(format!("{stem}/index.{ext}"));
}
push(base.clone());
}
Lang::Ruby => {
let cleaned = spec.trim_end_matches(".rb");
let joined = if dir.is_empty() {
cleaned.to_string()
} else {
format!("{dir}/{cleaned}")
};
push(format!("{joined}.rb"));
push(format!("{cleaned}.rb"));
push(format!("lib/{cleaned}.rb"));
if let Some(pkg) = root_pkg {
if let Some(rest) = cleaned.strip_prefix(&format!("{pkg}/")) {
push(format!("{rest}.rb"));
}
}
}
Lang::Rust => {
if let Some(m) = spec.strip_prefix("mod:") {
let base = if dir.is_empty() { m.to_string() } else { format!("{dir}/{m}") };
push(format!("{base}.rs"));
push(format!("{base}/mod.rs"));
} else {
let segs: Vec<&str> = spec.split("::").filter(|s| !s.is_empty()).collect();
let first = *segs.first()?;
let (mut base, rest): (String, &[&str]) = match first {
"crate" => (String::new(), &segs[1..]),
"self" => (dir.clone(), &segs[1..]),
"super" => {
let mut climbed = dir.clone();
let mut i = 0;
while segs.get(i) == Some(&"super") {
climbed = parent_dir(&climbed);
i += 1;
}
(climbed, &segs[i..])
}
_ => return None, };
if base == "." {
base = String::new();
}
for take in (1..=rest.len()).rev() {
let joined = rest[..take].join("/");
let full =
if base.is_empty() { joined.clone() } else { format!("{base}/{joined}") };
push(format!("{full}.rs"));
push(format!("{full}/mod.rs"));
}
}
}
Lang::Php => {
let as_path = spec.trim_start_matches('\\').replace('\\', "/");
push(format!("{as_path}.php"));
if let Some(pkg) = root_pkg {
if let Some(rest) = as_path.strip_prefix(&format!("{pkg}/")) {
push(format!("{rest}.php"));
}
}
if let Some((_, rest)) = as_path.split_once('/') {
push(format!("{rest}.php"));
}
if !dir.is_empty() {
push(format!("{dir}/{as_path}"));
}
push(as_path.clone());
}
}
candidates
.into_iter()
.find(|c| !c.is_empty() && c != from_rel && paths.contains(c))
}
pub(crate) enum FileAnalysis {
Measured {
metrics: FileMetrics,
functions: Vec<FunctionInfo>,
imports: Vec<String>,
fragments: Vec<CloneFragment>,
},
Refused { reason: String, parse_health: f64 },
}
pub(crate) fn analyze_source(
lang: Lang,
source: &str,
rel_path: &str,
has_tests: bool,
) -> Option<FileAnalysis> {
let mut parser = Parser::new();
parser.set_language(&lang.tree_sitter_language()).ok()?;
let tree = parser.parse(source, None)?;
let root = tree.root_node();
let src = source.as_bytes();
if depth_exceeds(root, MAX_AST_DEPTH) {
return Some(FileAnalysis::Refused {
reason: format!(
"AST nests deeper than {MAX_AST_DEPTH} levels - measuring it would overflow the stack"
),
parse_health: 0.0,
});
}
let health = round_dp(parse_health(root, source.lines().count()), 3);
if health < MIN_PARSE_HEALTH {
return Some(FileAnalysis::Refused {
reason: format!(
"only {:.0}% of lines parsed - metrics would be wrong, not just imprecise",
health * 100.0
),
parse_health: health,
});
}
let loc = count_loc(source, lang);
let mut fn_nodes: Vec<(Node, u32)> = Vec::new();
collect_functions(root, lang, src, &mut fn_nodes);
let mut functions: Vec<FunctionInfo> = Vec::with_capacity(fn_nodes.len());
let mut file_complexity: u32 = 0;
for (node, cc) in &fn_nodes {
file_complexity += *cc;
let start = node.start_position().row;
let end = node.end_position().row;
let fn_loc = source
.lines()
.skip(start)
.take(end.saturating_sub(start) + 1)
.filter(|l| is_code_line(l, lang))
.count();
functions.push(FunctionInfo {
name: function_display_name(*node, lang, src),
file: rel_path.to_string(),
line: start + 1,
complexity: *cc,
loc: fn_loc,
});
}
let num_functions = fn_nodes.len();
let avg_cc = if num_functions > 0 {
file_complexity as f64 / num_functions as f64
} else {
0.0
};
let vol = file_volume(root, src, lang);
let mi = round_dp(maintainability_index(vol, avg_cc, loc), 1);
let specs = extract_import_specs(root, lang, src);
let fm = FileMetrics {
path: rel_path.to_string(),
loc,
complexity: file_complexity,
avg_complexity: round_dp(avg_cc, 2),
functions: num_functions,
maintainability: mi,
halstead_volume: round_dp(vol, 2),
has_tests,
dep_count: specs.len(),
coupling_efferent: 0,
coupling_afferent: 0,
instability: 0.0,
parse_health: health,
};
let mut fragments: Vec<CloneFragment> = Vec::new();
collect_fragments(root, 0, &mut fragments);
Some(FileAnalysis::Measured { metrics: fm, functions, imports: specs, fragments })
}
const MIN_CLONE_NODES: u32 = 60;
const MIN_CLONE_LINES: usize = 6;
#[derive(Clone, Debug)]
pub(crate) struct CloneFragment {
hash: u64,
file: u32,
start_line: usize,
end_line: usize,
nodes: u32,
}
impl CloneFragment {
fn lines(&self) -> usize {
self.end_line.saturating_sub(self.start_line) + 1
}
fn inside(&self, other: &CloneFragment) -> bool {
self.file == other.file
&& self.start_line >= other.start_line
&& self.end_line <= other.end_line
}
}
fn shape_hash_of(kind: &str, child_hashes: &[u64]) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
kind.hash(&mut h);
child_hashes.len().hash(&mut h);
for c in child_hashes {
c.hash(&mut h);
}
h.finish()
}
fn collect_fragments(
node: Node,
file: u32,
out: &mut Vec<CloneFragment>,
) -> (u64, u32, bool) {
let mut child_hashes: Vec<u64> = Vec::new();
let mut count: u32 = 1;
let mut parsed_cleanly = !node.is_error() && !node.is_missing();
let mut c = node.walk();
if c.goto_first_child() {
loop {
let (h, n, clean) = collect_fragments(c.node(), file, out);
child_hashes.push(h);
count += n;
parsed_cleanly &= clean;
if !c.goto_next_sibling() {
break;
}
}
}
let hash = shape_hash_of(node.kind(), &child_hashes);
let start_line = node.start_position().row + 1;
let end_line = node.end_position().row + 1;
if parsed_cleanly
&& node.is_named()
&& count >= MIN_CLONE_NODES
&& end_line.saturating_sub(start_line) + 1 >= MIN_CLONE_LINES
{
out.push(CloneFragment { hash, file, start_line, end_line, nodes: count });
}
(hash, count, parsed_cleanly)
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct CloneGroup {
pub files: Vec<String>,
pub first_file: String,
pub first_line: usize,
pub copies: usize,
pub lines: usize,
pub cross_file: bool,
}
fn group_clones(mut frags: Vec<CloneFragment>, paths: &[String]) -> Vec<CloneGroup> {
frags.sort_by(|a, b| b.nodes.cmp(&a.nodes).then(a.file.cmp(&b.file)).then(a.start_line.cmp(&b.start_line)));
let mut by_hash: BTreeMap<u64, Vec<CloneFragment>> = BTreeMap::new();
for f in frags {
by_hash.entry(f.hash).or_default().push(f);
}
let mut candidates: Vec<Vec<CloneFragment>> =
by_hash.into_values().filter(|v| v.len() >= 2).collect();
candidates.sort_by(|a, b| b[0].nodes.cmp(&a[0].nodes));
let mut kept: Vec<Vec<CloneFragment>> = Vec::new();
for group in candidates {
let covered = group.iter().all(|f| {
kept.iter().any(|k| k.iter().any(|big| f.inside(big)))
});
if !covered {
kept.push(group);
}
}
let mut out: Vec<CloneGroup> = kept
.into_iter()
.map(|g| {
let mut files: Vec<String> = g
.iter()
.map(|f| paths.get(f.file as usize).cloned().unwrap_or_default())
.collect();
files.sort();
files.dedup();
let first = &g[0];
CloneGroup {
cross_file: files.len() > 1,
first_file: paths.get(first.file as usize).cloned().unwrap_or_default(),
first_line: first.start_line,
copies: g.len(),
lines: first.lines(),
files,
}
})
.collect();
out.sort_by(|a, b| {
(b.lines * b.copies)
.cmp(&(a.lines * a.copies))
.then(b.copies.cmp(&a.copies))
.then(a.first_file.cmp(&b.first_file))
});
out
}
const IGNORED_DIRS: &[&str] = &[
"node_modules", "vendor", ".git", "target", "dist", "build", "__pycache__",
".venv", "venv", "coverage", ".next", "out", ".tina4-docs", ".idea", ".pytest_cache",
".mypy_cache", ".ruff_cache", "site-packages",
];
fn is_generated_asset(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
name.ends_with(".min.js")
|| name.ends_with(".min.ts")
|| name.ends_with(".min.css")
|| name.ends_with(".bundle.js")
|| name.ends_with("-min.js")
|| name.ends_with(".map")
}
fn looks_minified(source: &str) -> bool {
let lines = source.lines().count();
if lines == 0 {
return false;
}
source.len() / lines > 200
}
fn is_generated_docs_dir(dir: &Path) -> bool {
["doxygen.css", "doxygen.svg", ".buildinfo"]
.iter()
.any(|marker| dir.join(marker).is_file())
}
fn walk_dir(dir: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else { return };
let mut items: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
items.sort();
for path in items {
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') || IGNORED_DIRS.contains(&name) {
continue;
}
if is_generated_docs_dir(&path) {
continue;
}
walk_dir(&path, files);
} else if Lang::from_path(&path).is_some() && !is_generated_asset(&path) {
files.push(path);
}
}
}
fn resolve_targets(path_flag: Option<&str>) -> Result<(Vec<PathBuf>, String), String> {
let mut files = Vec::new();
if let Some(p) = path_flag {
let pb = PathBuf::from(p);
if pb.is_file() {
if Lang::from_path(&pb).is_none() {
return Err(format!("unsupported file type: {p}"));
}
return Ok((vec![pb], p.to_string()));
}
if pb.is_dir() {
walk_dir(&pb, &mut files);
return Ok((files, p.to_string()));
}
return Err(format!("Directory not found: {p}"));
}
let src = PathBuf::from("src");
if src.is_dir() {
walk_dir(&src, &mut files);
if !files.is_empty() {
return Ok((files, "src".to_string()));
}
}
let packages = PathBuf::from("packages");
if packages.is_dir() {
if let Ok(entries) = fs::read_dir(&packages) {
let mut pkg_dirs: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
pkg_dirs.sort();
for pkg in pkg_dirs {
let pkg_src = pkg.join("src");
if pkg_src.is_dir() {
walk_dir(&pkg_src, &mut files);
}
}
}
if !files.is_empty() {
return Ok((files, "packages/*/src".to_string()));
}
}
walk_dir(Path::new("."), &mut files);
Ok((files, ".".to_string()))
}
fn rel_display(file: &Path, root: &str) -> String {
let root_path = Path::new(root);
let rel = if root_path.is_file() {
file.file_name().map(PathBuf::from).unwrap_or_else(|| file.to_path_buf())
} else {
file.strip_prefix(root_path).unwrap_or(file).to_path_buf()
};
rel.to_string_lossy().replace('\\', "/")
}
struct TestIndex {
file_names: HashSet<String>,
contents: Vec<String>,
}
fn build_test_index(root: &str) -> TestIndex {
let mut file_names = HashSet::new();
let mut contents = Vec::new();
let base = {
let p = Path::new(root);
if p.is_file() { p.parent().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")) } else { p.to_path_buf() }
};
let mut roots: Vec<PathBuf> = vec![PathBuf::from(".")];
let mut cur = base.clone();
for _ in 0..6 {
roots.push(cur.clone());
match cur.parent() {
Some(p) if p != cur => cur = p.to_path_buf(),
_ => break,
}
}
for r in roots {
for td in ["tests", "test", "spec"] {
let dir = r.join(td);
if dir.is_dir() {
let mut tf = Vec::new();
walk_dir(&dir, &mut tf);
for f in tf {
if let Some(name) = f.file_name().and_then(|n| n.to_str()) {
file_names.insert(name.to_ascii_lowercase());
}
if let Ok(c) = fs::read_to_string(&f) {
contents.push(c);
}
}
}
}
}
TestIndex { file_names, contents }
}
fn declared_type_names(source: &str, lang: Lang) -> Vec<String> {
let mut parser = Parser::new();
if parser.set_language(&lang.tree_sitter_language()).is_err() {
return Vec::new();
}
let Some(tree) = parser.parse(source, None) else { return Vec::new() };
let bytes = source.as_bytes();
let mut names = Vec::new();
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
if is_type_decl_node(node.kind(), lang) {
if let Some(name) = node_name(node, bytes) {
names.push(name);
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
stack.push(child);
}
}
names
}
fn mentions_symbol(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let bytes = haystack.as_bytes();
let n = needle.len();
let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let mut from = 0usize;
while let Some(rel) = haystack[from..].find(needle) {
let start = from + rel;
let end = start + n;
let before_ok = start == 0 || !is_ident(bytes[start - 1]);
let after_ok = end == bytes.len() || !is_ident(bytes[end]);
if before_ok && after_ok {
return true;
}
from = start + 1;
}
false
}
fn module_has_tests(file: &Path, idx: &TestIndex, declared_types: &[String]) -> bool {
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let stem = if matches!(stem, "__init__" | "index" | "mod") {
file.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str()).unwrap_or(stem)
} else {
stem
};
if stem.is_empty() {
return false;
}
let stem_l = stem.to_ascii_lowercase();
for pat in [
format!("test_{stem_l}."),
format!("test_{stem_l}s."),
format!("{stem_l}_test."),
format!("{stem_l}_spec."),
format!("{stem_l}.test."),
format!("{stem_l}.spec."),
format!("{stem_l}test."),
] {
if idx.file_names.iter().any(|n| n.starts_with(&pat)) {
return true;
}
}
for content in &idx.contents {
for line in content.lines() {
let t = line.trim_start();
let is_import = t.starts_with("import ")
|| t.starts_with("from ")
|| t.starts_with("require")
|| t.starts_with("use ")
|| t.contains("require(");
if is_import && line.contains(stem) {
return true;
}
}
}
for ty in declared_types {
for content in &idx.contents {
if mentions_symbol(content, ty) {
return true;
}
}
}
false
}
fn rust_has_inline_tests(source: &str) -> bool {
source.contains("#[cfg(test)]")
}
#[derive(Serialize, Clone, Debug)]
pub(crate) struct RefusedFile {
pub path: String,
pub parse_health: f64,
pub lines: usize,
pub reason: String,
}
#[derive(Serialize, Clone)]
pub(crate) struct Offender {
pub file: String,
pub line: usize,
pub kind: String,
pub severity: String,
pub score: f64,
pub detail: String,
}
fn severity_rank(sev: &str) -> u8 {
match sev {
"error" => 2,
"warn" => 1,
_ => 0,
}
}
fn build_offenders(files: &[FileMetrics], functions: &[FunctionInfo]) -> Vec<Offender> {
let mut items: Vec<Offender> = Vec::new();
let mut by_cc: Vec<&FunctionInfo> = functions.iter().collect();
by_cc.sort_by(|a, b| b.complexity.cmp(&a.complexity));
for fn_info in by_cc.iter() {
let cc = fn_info.complexity;
if cc > 10 {
items.push(Offender {
file: fn_info.file.clone(),
line: fn_info.line,
kind: "complexity".to_string(),
severity: if cc > 20 { "error" } else { "warn" }.to_string(),
score: cc as f64,
detail: format!("{} - cyclomatic complexity {}", fn_info.name, cc),
});
}
}
let mut by_mi: Vec<&FileMetrics> = files.iter().collect();
by_mi.sort_by(|a, b| a.maintainability.partial_cmp(&b.maintainability).unwrap_or(std::cmp::Ordering::Equal));
for fm in by_mi {
if fm.loc > 500 {
items.push(Offender {
file: fm.path.clone(),
line: 1,
kind: "large_file".to_string(),
severity: "warn".to_string(),
score: fm.loc as f64 / 100.0,
detail: format!("{} LOC (max 500)", fm.loc),
});
}
if fm.functions > 20 {
items.push(Offender {
file: fm.path.clone(),
line: 1,
kind: "too_many_functions".to_string(),
severity: "warn".to_string(),
score: fm.functions as f64 / 4.0,
detail: format!("{} functions (max 20)", fm.functions),
});
}
const LOW_MI_MIN_AVG_CC: f64 = 5.0;
if fm.maintainability < 40.0 && fm.avg_complexity >= LOW_MI_MIN_AVG_CC {
items.push(Offender {
file: fm.path.clone(),
line: 1,
kind: "low_maintainability".to_string(),
severity: if fm.maintainability < 20.0 { "error" } else { "warn" }.to_string(),
score: 50.0 - fm.maintainability,
detail: format!(
"maintainability index {:.1} (min 40), avg complexity {:.1}",
fm.maintainability, fm.avg_complexity
),
});
}
if !fm.has_tests {
items.push(Offender {
file: fm.path.clone(),
line: 1,
kind: "untested".to_string(),
severity: "info".to_string(),
score: fm.loc as f64 / 100.0,
detail: "no referencing test".to_string(),
});
}
}
sort_offenders(&mut items);
items
}
fn sort_offenders(items: &mut [Offender]) {
items.sort_by(|a, b| {
severity_rank(&b.severity)
.cmp(&severity_rank(&a.severity))
.then(b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal))
});
}
fn refusal_offenders(refused: &[RefusedFile]) -> Vec<Offender> {
refused
.iter()
.map(|r| Offender {
file: r.path.clone(),
line: 1,
kind: "unparsed".to_string(),
severity: "warn".to_string(),
score: r.lines as f64 / 100.0,
detail: format!("NOT MEASURED - {}", r.reason),
})
.collect()
}
fn clone_offenders(groups: &[CloneGroup]) -> Vec<Offender> {
groups
.iter()
.map(|g| {
let wasted = g.lines * g.copies;
let where_ = if g.cross_file {
format!(" across {} files", g.files.len())
} else {
String::new()
};
Offender {
file: g.first_file.clone(),
line: g.first_line,
kind: "duplication".to_string(),
severity: if wasted >= 60 || (g.cross_file && wasted >= 40) {
"error"
} else {
"warn"
}
.to_string(),
score: wasted as f64,
detail: format!(
"{} duplicated lines x {} copies{} - {} lines could be removed",
g.lines,
g.copies,
where_,
g.lines * (g.copies - 1)
),
}
})
.collect()
}
#[derive(Serialize)]
struct Summary {
files_analyzed: usize,
total_functions: usize,
avg_complexity: f64,
avg_maintainability: f64,
scan_mode: String,
scan_root: String,
total_offenders: usize,
duplicate_blocks: usize,
duplicate_lines: usize,
files_refused: usize,
}
#[derive(Serialize)]
struct JsonPayload {
summary: Summary,
offenders: Vec<Offender>,
file_metrics: Vec<FileMetrics>,
most_complex_functions: Vec<FunctionInfo>,
dependency_graph: BTreeMap<String, Vec<String>>,
duplication: Vec<CloneGroup>,
unparsed: Vec<RefusedFile>,
}
pub(crate) struct Report {
files: Vec<FileMetrics>,
functions: Vec<FunctionInfo>,
offenders: Vec<Offender>,
scan_root: String,
dependency_graph: BTreeMap<String, Vec<String>>,
clones: Vec<CloneGroup>,
refused: Vec<RefusedFile>,
}
pub(crate) fn analyze_targets(files: &[PathBuf], scan_root: &str) -> Report {
let test_index = build_test_index(scan_root);
let mut file_metrics: Vec<FileMetrics> = Vec::new();
let mut all_functions: Vec<FunctionInfo> = Vec::new();
let mut pending: Vec<(String, Lang, Vec<String>)> = Vec::new();
let mut fragments: Vec<CloneFragment> = Vec::new();
let mut clone_paths: Vec<String> = Vec::new();
let mut refused: Vec<RefusedFile> = Vec::new();
for path in files {
let Some(lang) = Lang::from_path(path) else { continue };
let Ok(source) = fs::read_to_string(path) else { continue };
if looks_minified(&source) {
continue;
}
let rel = rel_display(path, scan_root);
let declared = declared_type_names(&source, lang);
let has_tests = (lang == Lang::Rust && rust_has_inline_tests(&source))
|| module_has_tests(path, &test_index, &declared);
match analyze_source(lang, &source, &rel, has_tests) {
Some(FileAnalysis::Refused { reason, parse_health }) => {
refused.push(RefusedFile {
path: rel,
parse_health,
lines: source.lines().count(),
reason,
});
}
Some(FileAnalysis::Measured { metrics, functions, imports, fragments: frags }) => {
let idx = clone_paths.len() as u32;
clone_paths.push(metrics.path.clone());
fragments.extend(frags.into_iter().map(|f| CloneFragment { file: idx, ..f }));
pending.push((metrics.path.clone(), lang, imports));
file_metrics.push(metrics);
all_functions.extend(functions);
}
None => {}
}
}
let paths: HashSet<String> = file_metrics.iter().map(|f| f.path.clone()).collect();
let root_pkg = Path::new(scan_root)
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
let mut dependency_graph: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut afferent: HashMap<String, usize> = HashMap::new();
for (from_rel, lang, specs) in &pending {
let mut targets: Vec<String> = Vec::new();
for spec in specs {
if let Some(target) =
resolve_import(spec, from_rel, *lang, &paths, root_pkg.as_deref())
{
if !targets.contains(&target) {
targets.push(target);
}
}
}
for t in &targets {
*afferent.entry(t.clone()).or_insert(0) += 1;
}
targets.sort();
dependency_graph.insert(from_rel.clone(), targets);
}
for fm in file_metrics.iter_mut() {
let ce = dependency_graph.get(&fm.path).map_or(0, |v| v.len());
let ca = *afferent.get(&fm.path).unwrap_or(&0);
fm.coupling_efferent = ce;
fm.coupling_afferent = ca;
fm.instability = if ca + ce > 0 {
round_dp(ce as f64 / (ca + ce) as f64, 3)
} else {
0.0
};
}
let clones = group_clones(fragments, &clone_paths);
let mut offenders = build_offenders(&file_metrics, &all_functions);
offenders.extend(clone_offenders(&clones));
offenders.extend(refusal_offenders(&refused));
sort_offenders(&mut offenders);
Report {
files: file_metrics,
functions: all_functions,
offenders,
scan_root: scan_root.to_string(),
dependency_graph,
clones,
refused,
}
}
fn build_summary(report: &Report, total_offenders: usize) -> Summary {
let total_cc: u32 = report.functions.iter().map(|f| f.complexity).sum();
let avg_complexity = if report.functions.is_empty() {
0.0
} else {
round_dp(total_cc as f64 / report.functions.len() as f64, 2)
};
let total_mi: f64 = report.files.iter().map(|f| f.maintainability).sum();
let avg_maintainability = if report.files.is_empty() {
0.0
} else {
round_dp(total_mi / report.files.len() as f64, 1)
};
Summary {
files_analyzed: report.files.len(),
total_functions: report.functions.len(),
avg_complexity,
avg_maintainability,
scan_mode: "project".to_string(),
scan_root: report.scan_root.clone(),
total_offenders,
duplicate_blocks: report.clones.len(),
duplicate_lines: report.clones.iter().map(|c| c.lines * (c.copies - 1)).sum(),
files_refused: report.refused.len(),
}
}
fn compute_exit_code(fail_on: Option<&str>, has_warn: bool, has_error: bool) -> i32 {
match fail_on {
Some("warn") if has_warn || has_error => 1,
Some("error") if has_error => 1,
_ => 0,
}
}
pub fn run(path: Option<String>, top: Option<usize>, json: bool, fail_on: Option<String>) -> i32 {
if let Some(f) = &fail_on {
if f != "warn" && f != "error" {
eprintln!(" invalid --fail-on '{f}' (use warn or error)");
return 2;
}
}
let top = top.unwrap_or(20);
let (files, scan_root) = match resolve_targets(path.as_deref()) {
Ok(v) => v,
Err(e) => {
if json {
println!("{{\n \"summary\": {{\n \"error\": {}\n }},\n \"offenders\": []\n}}", serde_json::to_string(&e).unwrap_or_default());
} else {
println!(" metrics error: {e}");
}
return 2;
}
};
let report = analyze_targets(&files, &scan_root);
let total_offenders = report.offenders.len();
let summary = build_summary(&report, total_offenders);
let has_warn = report.offenders.iter().any(|o| o.severity == "warn");
let has_error = report.offenders.iter().any(|o| o.severity == "error");
let exit_code = compute_exit_code(fail_on.as_deref(), has_warn, has_error);
let shown: Vec<Offender> = report.offenders.iter().take(top).cloned().collect();
if json {
let mut by_cc: Vec<FunctionInfo> = report.functions.clone();
by_cc.sort_by(|a, b| b.complexity.cmp(&a.complexity));
by_cc.truncate(15);
let payload = JsonPayload {
summary,
offenders: shown,
file_metrics: report.files.clone(),
most_complex_functions: by_cc,
dependency_graph: report.dependency_graph.clone(),
duplication: report.clones.clone(),
unparsed: report.refused.clone(),
};
println!("{}", serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()));
return exit_code;
}
print_human(&summary, &shown);
exit_code
}
fn print_human(summary: &Summary, shown: &[Offender]) {
use std::io::IsTerminal;
let use_color = std::io::stdout().is_terminal();
let paint = |text: &str, code: &str| -> String {
if use_color {
format!("\u{1b}[{code}m{text}\u{1b}[0m")
} else {
text.to_string()
}
};
println!();
println!(" Tina4 Metrics \u{2014} {} scan ({})", summary.scan_mode, summary.scan_root);
println!(
" files: {} functions: {} avg complexity: {} avg maintainability: {}",
summary.files_analyzed, summary.total_functions, summary.avg_complexity, summary.avg_maintainability
);
if summary.files_refused > 0 {
println!(
" {}",
paint(
&format!(
"! {} file(s) NOT MEASURED - the engine could not read them (see `unparsed` offenders)",
summary.files_refused
),
"33"
)
);
}
if summary.duplicate_blocks > 0 {
println!(
" duplication: {} repeated blocks {} lines removable by unifying them",
summary.duplicate_blocks, summary.duplicate_lines
);
}
let showing = if shown.is_empty() { String::new() } else { format!(" (showing top {})", shown.len()) };
println!(" offenders: {} total{}", summary.total_offenders, showing);
println!();
if shown.is_empty() {
if summary.files_analyzed == 0 {
println!(
" {}",
paint("no supported source files found - nothing was measured", "33")
);
} else {
println!(" {}", paint("\u{2713} no offenders \u{2014} clean", "32"));
}
println!();
return;
}
let loc_cells: Vec<String> = shown.iter().map(|o| format!("{}:{}", o.file, o.line)).collect();
let loc_w = loc_cells.iter().map(|s| s.len()).chain(std::iter::once("FILE:LINE".len())).max().unwrap_or(9);
let kind_w = shown.iter().map(|o| o.kind.len()).chain(std::iter::once("KIND".len())).max().unwrap_or(4);
let header = format!(
" {:>3} {:<8} {:<kw$} {:<lw$} DETAIL",
"#", "SEVERITY", "KIND", "FILE:LINE", kw = kind_w, lw = loc_w
);
println!("{}", paint(&header, "1"));
println!(" {}", "-".repeat(header.len().saturating_sub(2)));
for (i, o) in shown.iter().enumerate() {
let code = match o.severity.as_str() {
"error" => "31",
"warn" => "33",
_ => "2",
};
let sev_cell = paint(&format!("{:<8}", o.severity), code);
println!(
" {:>3} {} {:<kw$} {:<lw$} {}",
i + 1, sev_cell, o.kind, loc_cells[i], o.detail, kw = kind_w, lw = loc_w
);
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn manifest() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn read_fixture(name: &str) -> String {
std::fs::read_to_string(manifest().join("tests/fixtures").join(name)).unwrap()
}
fn measured(
lang: Lang,
src: &str,
rel: &str,
has_tests: bool,
) -> (FileMetrics, Vec<FunctionInfo>, Vec<String>) {
match analyze_source(lang, src, rel, has_tests).expect("grammar must load") {
FileAnalysis::Measured { metrics, functions, imports, .. } => {
(metrics, functions, imports)
}
FileAnalysis::Refused { reason, parse_health } => {
panic!("{rel}: REFUSED at parse health {parse_health} - {reason}")
}
}
}
fn analyze_py(src: &str) -> (FileMetrics, Vec<FunctionInfo>) {
let (fm, fns, _specs) = measured(Lang::Python, src, "t.py", false);
(fm, fns)
}
fn analyze_ts(src: &str) -> (FileMetrics, Vec<FunctionInfo>) {
let (fm, fns, _specs) = measured(Lang::Ts, src, "t.ts", false);
(fm, fns)
}
fn analyze_php(src: &str) -> (FileMetrics, Vec<FunctionInfo>) {
let (fm, fns, _specs) = measured(Lang::Php, src, "t.php", false);
(fm, fns)
}
fn analyze_rb(src: &str) -> (FileMetrics, Vec<FunctionInfo>) {
let (fm, fns, _specs) = measured(Lang::Ruby, src, "t.rb", false);
(fm, fns)
}
fn analyze_rs(src: &str) -> (FileMetrics, Vec<FunctionInfo>) {
let (fm, fns, _specs) = measured(Lang::Rust, src, "t.rs", false);
(fm, fns)
}
#[test]
fn rust_counts_every_decision_kind_once() {
let src = "\
fn f(a: i32, b: Option<i32>, c: Result<i32, String>) -> Result<i32, String> {
if a > 0 { return Ok(1); }
if let Some(v) = b { let _ = v; }
while a > 0 { break; }
loop { break; }
for i in 0..10 { let _ = i; }
let _q = c?;
let _z = a > 0 && a < 10;
Ok(0)
}
";
let (fm, fns) = analyze_rs(src);
assert_eq!(fm.functions, 1);
assert_eq!(fns[0].complexity, 8, "1 + if + if-let + while + loop + for + ? + &&");
}
#[test]
fn rust_try_operator_is_a_real_branch() {
let one = "fn f(c: Result<i32, String>) -> Result<i32, String> { Ok(c?) }\n";
assert_eq!(analyze_rs(one).1[0].complexity, 2, "1 + one ?");
let three = "fn f(a: Result<i32, String>, b: Result<i32, String>, c: Result<i32, String>) -> Result<i32, String> { Ok(a? + b? + c?) }\n";
assert_eq!(analyze_rs(three).1[0].complexity, 4, "1 + three ?");
let none = "fn f(c: i32) -> i32 { c }\n";
assert_eq!(analyze_rs(none).1[0].complexity, 1);
}
#[test]
fn rust_else_is_not_a_decision_but_else_if_is() {
let if_else = "fn f(a: i32) -> i32 { if a > 0 { 1 } else { 2 } }\n";
assert_eq!(analyze_rs(if_else).1[0].complexity, 2, "if/else = 1 decision");
let chain = "fn f(a: i32) -> i32 { if a > 0 { 1 } else if a < 0 { 2 } else { 3 } }\n";
assert_eq!(analyze_rs(chain).1[0].complexity, 3, "if / else-if / else = 2 decisions");
}
#[test]
fn rust_wildcard_match_arm_is_not_a_decision() {
let src = "fn f(a: i32) -> i32 { match a { 1 => 1, 2 => 2, _ => 0 } }\n";
assert_eq!(analyze_rs(src).1[0].complexity, 3, "1 + two real arms, wildcard excluded");
let exhaustive = "fn f(a: bool) -> i32 { match a { true => 1, false => 0 } }\n";
assert_eq!(analyze_rs(exhaustive).1[0].complexity, 3, "1 + two arms");
}
#[test]
fn rust_closure_decisions_stay_with_the_enclosing_function() {
let src = "fn f(xs: Vec<i32>) -> Vec<i32> { xs.into_iter().map(|x| if x > 0 { 1 } else { 0 }).collect() }\n";
let (fm, fns) = analyze_rs(src);
assert_eq!(fm.functions, 1, "the closure is not a function of its own");
assert_eq!(fns[0].complexity, 2, "1 + the closure's if");
}
#[test]
fn rust_impl_method_is_named_for_its_type() {
let inherent = "struct Point;\nimpl Point { fn new() -> Self { Point } }\n";
let (_fm, fns) = analyze_rs(inherent);
assert_eq!(fns[0].name, "Point.new");
let trait_impl = "struct Point;\ntrait Draw { fn draw(&self); }\nimpl Draw for Point { fn draw(&self) {} }\n";
let (_fm, fns) = analyze_rs(trait_impl);
assert!(
fns.iter().any(|f| f.name == "Point.draw"),
"a trait impl is named for the implementing TYPE, not the trait: {:?}",
fns.iter().map(|f| &f.name).collect::<Vec<_>>()
);
}
#[test]
fn rust_loc_excludes_line_doc_and_block_comments() {
let src = "\
// a line comment
/// a doc comment
//! an inner doc comment
/* a block comment
* continued
*/
fn f() -> i32 {
1
}
";
let (fm, _fns) = analyze_rs(src);
assert_eq!(fm.loc, 3, "six comment lines must not count as code");
}
#[test]
fn rust_negative_control_a_clean_file_scores_clean() {
let src = "\
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn sub(a: i32, b: i32) -> i32 {
a - b
}
";
let (fm, fns, _s) = measured(Lang::Rust, src, "clean.rs", true);
assert_eq!(fm.functions, 2);
assert!(fns.iter().all(|f| f.complexity == 1), "straight-line code is CC 1");
assert!(
fm.maintainability > 60.0,
"a trivial module must score HIGH (well clear of the 40 offender line), got {}",
fm.maintainability
);
assert!(
build_offenders(&[fm], &fns).is_empty(),
"a clean file must raise no offender at all"
);
}
#[test]
fn rust_maintainability_is_in_band_with_the_other_languages() {
let cases = [
(
"rust",
Lang::Rust,
"pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\npub fn sub(a: i32, b: i32) -> i32 {\n a - b\n}\n",
),
(
"ts",
Lang::Ts,
"export function add(a: number, b: number): number {\n return a + b;\n}\n\nexport function sub(a: number, b: number): number {\n return a - b;\n}\n",
),
(
"php",
Lang::Php,
"<?php\nfunction add($a, $b) {\n return $a + $b;\n}\n\nfunction sub($a, $b) {\n return $a - $b;\n}\n",
),
(
"ruby",
Lang::Ruby,
"def add(a, b)\n a + b\nend\n\ndef sub(a, b)\n a - b\nend\n",
),
];
let mut scores = Vec::new();
for (name, lang, src) in cases {
let (fm, _f, _s) = measured(lang, src, "t", false);
assert_eq!(fm.functions, 2, "{name}: both functions must be found");
scores.push((name, fm.maintainability));
}
let rust = scores.iter().find(|(n, _)| *n == "rust").unwrap().1;
for (name, mi) in &scores {
assert!(
(rust - mi).abs() < 8.0,
"rust MI {rust} is out of band with {name} MI {mi} for identical logic \
- the Rust decision set or operand list has drifted"
);
}
}
#[test]
fn rust_mod_and_crate_paths_resolve_to_real_files() {
let src = "mod agent;\npub mod console;\nuse crate::console::icon_ok;\nuse std::fs;\nuse serde::Serialize;\n";
let (_fm, _fns, specs) = measured(Lang::Rust, src, "main.rs", false);
assert!(specs.contains(&"mod:agent".to_string()), "got {specs:?}");
assert!(specs.contains(&"mod:console".to_string()), "got {specs:?}");
assert!(specs.contains(&"crate::console::icon_ok".to_string()), "got {specs:?}");
let paths = pathset(&["main.rs", "agent.rs", "console.rs"]);
assert_eq!(
resolve_import("mod:agent", "main.rs", Lang::Rust, &paths, None),
Some("agent.rs".to_string())
);
assert_eq!(
resolve_import("crate::console::icon_ok", "main.rs", Lang::Rust, &paths, None),
Some("console.rs".to_string()),
"the item tail is dropped once the module prefix matches a file"
);
assert_eq!(
resolve_import("console::Term", "main.rs", Lang::Rust, &paths, None),
None,
"a BARE first segment is an external crate even when a local file shares its name"
);
assert_eq!(
resolve_import("crate::console::Term", "main.rs", Lang::Rust, &paths, None),
Some("console.rs".to_string()),
"the same path via `crate::` IS the local file - this is the contrast that makes the rule meaningful"
);
assert_eq!(resolve_import("std::fs", "main.rs", Lang::Rust, &paths, None), None);
assert_eq!(resolve_import("serde::Serialize", "main.rs", Lang::Rust, &paths, None), None);
}
#[test]
fn adding_rust_does_not_perturb_the_other_languages() {
for (name, lang, src) in [
(
"ts",
Lang::Ts,
"export function add(a: number, b: number): number {\n return a + b;\n}\n\nexport function sub(a: number, b: number): number {\n return a - b;\n}\n",
),
(
"php",
Lang::Php,
"<?php\nfunction add($a, $b) {\n return $a + $b;\n}\n\nfunction sub($a, $b) {\n return $a - $b;\n}\n",
),
(
"ruby",
Lang::Ruby,
"def add(a, b)\n a + b\nend\n\ndef sub(a, b)\n a - b\nend\n",
),
] {
let (fm, _f, _s) = measured(lang, src, "t", false);
assert_eq!(
fm.halstead_volume, 31.02,
"{name}: Halstead volume moved - the Rust operand kinds have leaked \
into the shared list and every {name} MI in the audit is now wrong"
);
}
let typed = "<?php\nfunction add(int $a, int $b): int {\n return $a + $b;\n}\n";
let (fm, _f, _s) = measured(Lang::Php, typed, "t.php", false);
assert_eq!(
fm.halstead_volume, 12.0,
"PHP `primitive_type` must stay uncounted"
);
}
#[test]
fn pascal_is_not_claimed() {
for ext in ["pas", "dpr", "dpk", "inc", "PAS"] {
assert_eq!(
Lang::from_path(Path::new(&format!("a.{ext}"))),
None,
"{ext} must not be claimed while no grammar can parse the corpus"
);
}
}
#[test]
fn rust_inline_cfg_test_module_counts_as_tested() {
assert!(rust_has_inline_tests("fn f() {}\n#[cfg(test)]\nmod tests { #[test] fn t() {} }\n"));
assert!(!rust_has_inline_tests("fn f() -> i32 { 1 }\n"));
assert!(
!rust_has_inline_tests("// mentions cfg and test but declares neither\n"),
"the marker is the attribute, not the words"
);
}
#[test]
fn detects_language_from_extension() {
for ext in ["py", "pyw"] {
assert_eq!(Lang::from_path(Path::new(&format!("a.{ext}"))), Some(Lang::Python));
}
assert_eq!(Lang::from_path(Path::new("a.php")), Some(Lang::Php));
assert_eq!(Lang::from_path(Path::new("a.rb")), Some(Lang::Ruby));
for ext in ["ts", "tsx", "js", "jsx", "mjs"] {
assert_eq!(Lang::from_path(Path::new(&format!("a.{ext}"))), Some(Lang::Ts));
}
assert_eq!(Lang::from_path(Path::new("a.rs")), Some(Lang::Rust));
assert_eq!(Lang::from_path(Path::new("A.RS")), Some(Lang::Rust), "extension match is case-insensitive");
assert_eq!(Lang::from_path(Path::new("a.md")), None);
assert_eq!(Lang::from_path(Path::new("noext")), None);
assert_eq!(Lang::from_path(Path::new("a.pas")), None);
}
#[test]
fn cyclomatic_complexity_counts_decision_points_python() {
let src = "def f(x):\n if x and x > 0:\n for i in x:\n pass\n return x\n";
let (fm, fns) = analyze_py(src);
assert_eq!(fm.functions, 1);
assert_eq!(fns[0].complexity, 4, "CC should be 1+if+and+for");
assert_eq!(fm.complexity, 4);
}
#[test]
fn comprehension_and_ternary_add_complexity_python() {
let src = "def g(items):\n xs = [n for n in items if n]\n return 1 if items else 0\n";
let (_fm, fns) = analyze_py(src);
assert_eq!(fns[0].complexity, 4);
}
#[test]
fn maintainability_index_is_bounded_and_named_with_class() {
let src = "class Foo:\n def bar(self):\n return 1\n";
let (fm, fns) = analyze_py(src);
assert!(fm.maintainability >= 0.0 && fm.maintainability <= 100.0);
assert_eq!(fns[0].name, "Foo.bar", "method name carries its class prefix");
}
#[test]
fn empty_file_scores_full_maintainability() {
let (fm, _f) = analyze_py("\n\n# just a comment\n");
assert_eq!(fm.loc, 0);
assert_eq!(fm.maintainability, 100.0);
assert_eq!(fm.functions, 0);
}
#[test]
fn a_parent_is_not_charged_for_its_nested_functions_python() {
let src = "def outer(a):\n def inner1(x):\n if x: return 1\n if x > 2: return 2\n return 3\n def inner2(y):\n if y: return 1\n if y > 2: return 2\n return 3\n return inner1(a) + inner2(a)\n";
let (fm, fns) = analyze_py(src);
let outer = fns.iter().find(|f| f.name == "outer").unwrap();
assert_eq!(outer.complexity, 1, "outer branches on nothing itself");
for name in ["inner1", "inner2"] {
let f = fns.iter().find(|f| f.name == name).unwrap();
assert_eq!(f.complexity, 3, "{name} keeps its own two branches");
}
assert_eq!(fm.complexity, 7, "1 + 3 + 3, with nothing counted twice");
}
#[test]
fn a_python_lambda_still_counts_toward_its_enclosing_function() {
let src = "def f(xs):\n return sorted(xs, key=lambda x: 1 if x else 0)\n";
let (_fm, fns) = analyze_py(src);
assert_eq!(fns.len(), 1, "the lambda is not reported separately");
assert_eq!(fns[0].complexity, 2, "1 + the lambda's ternary");
}
#[test]
fn a_method_in_a_nested_class_is_not_charged_to_the_outer_function() {
let src = "def make():\n class Inner:\n def go(self, x):\n if x: return 1\n return 2\n return Inner\n";
let (_fm, fns) = analyze_py(src);
let outer = fns.iter().find(|f| f.name == "make").unwrap();
assert_eq!(outer.complexity, 1, "the nested class body is a separate scope");
let go = fns.iter().find(|f| f.name.ends_with("go")).unwrap();
assert_eq!(go.complexity, 2);
}
#[test]
fn an_iife_wrapper_does_not_absorb_the_whole_module_typescript() {
let src = "(function () {\n function a(x) { if (x) { return 1; } return 2; }\n function b(y) { if (y) { return 1; } return 2; }\n return { a: a, b: b };\n})();\n";
let (_fm, fns) = analyze_ts(src);
let wrapper = fns.iter().min_by_key(|f| f.line).unwrap();
assert_eq!(wrapper.complexity, 1, "the wrapper itself branches on nothing");
assert!(fns.iter().any(|f| f.complexity == 2), "inner functions keep theirs");
}
#[test]
fn ruby_keyword_tokens_are_not_counted_as_decisions() {
for (src, expected, what) in [
("def m(y)\n return 1 if y\n 2\nend\n", 2, "modifier if"),
("def m(z)\n if z\n 1\n else\n 2\n end\nend\n", 2, "if/else"),
("def m(z)\n while z\n z -= 1\n end\nend\n", 2, "while"),
("def m(z)\n z.each { |i| puts i }\n 1\nend\n", 1, "a block is not a decision"),
] {
let (_fm, fns) = analyze_rb(src);
assert_eq!(fns[0].complexity, expected, "{what}");
}
}
#[test]
fn a_php_closure_counts_toward_its_enclosing_method() {
let php = "<?php\nclass A {\n function outer($x) {\n return array_map(function ($y) { if ($y) { return 1; } return 2; }, $x);\n }\n}\n";
let (_fm, fns) = analyze_php(php);
let outer = fns.iter().find(|f| f.name.ends_with("outer")).unwrap();
assert_eq!(outer.complexity, 2, "1 + the closure's if");
}
#[test]
fn nested_methods_do_not_double_count_php_and_ruby() {
let php = "<?php\nclass A {\n function outer($x) {\n function helper($y) { if ($y) { return 1; } return 2; }\n return helper($x);\n }\n}\n";
let (_fm, fns) = analyze_php(php);
let outer = fns.iter().find(|f| f.name.ends_with("outer")).unwrap();
assert_eq!(outer.complexity, 1, "helper's branch belongs to helper");
let helper = fns.iter().find(|f| f.name.ends_with("helper")).unwrap();
assert_eq!(helper.complexity, 2);
let rb = "class A\n def outer(x)\n inner(x)\n end\n def inner(y)\n return 1 if y\n 2\n end\nend\n";
let (_fm, fns) = analyze_rb(rb);
let outer = fns.iter().find(|f| f.name.ends_with("outer")).unwrap();
assert_eq!(outer.complexity, 1);
let inner = fns.iter().find(|f| f.name.ends_with("inner")).unwrap();
assert_eq!(inner.complexity, 2);
}
fn file_with(mi: f64, loc: usize, funcs: usize, has_tests: bool) -> FileMetrics {
file_with_cc(mi, loc, funcs, has_tests, 8.0)
}
fn file_with_cc(mi: f64, loc: usize, funcs: usize, has_tests: bool, avg_cc: f64) -> FileMetrics {
FileMetrics {
path: "x.py".into(), loc, complexity: (avg_cc * funcs as f64) as u32,
avg_complexity: avg_cc,
functions: funcs, maintainability: mi, halstead_volume: 0.0, has_tests,
dep_count: 0, coupling_efferent: 0, coupling_afferent: 0, instability: 0.0,
parse_health: 1.0,
}
}
fn func_with(cc: u32) -> FunctionInfo {
FunctionInfo { name: "f".into(), file: "x.py".into(), line: 1, complexity: cc, loc: 1 }
}
#[test]
fn offender_low_maintainability_severity_split_at_20() {
let warn = build_offenders(&[file_with(35.0, 10, 1, true)], &[]);
assert_eq!(warn[0].kind, "low_maintainability");
assert_eq!(warn[0].severity, "warn");
let err = build_offenders(&[file_with(15.0, 10, 1, true)], &[]);
assert_eq!(err[0].severity, "error");
let clean = build_offenders(&[file_with(55.0, 10, 1, true)], &[]);
assert!(clean.iter().all(|o| o.kind != "low_maintainability"));
}
#[test]
fn low_maintainability_does_not_fire_on_a_big_but_simple_file() {
let big_simple = file_with_cc(8.0, 1200, 400, true, 1.0);
let offs = build_offenders(&[big_simple], &[]);
assert!(
offs.iter().all(|o| o.kind != "low_maintainability"),
"low_maintainability fired on a big-but-simple file (avg CC 1.0) - it is re-flagging size"
);
assert!(offs.iter().any(|o| o.kind == "large_file"));
}
#[test]
fn low_maintainability_still_fires_when_functions_are_complex() {
let complex = file_with_cc(15.0, 1200, 400, true, 8.0);
let offs = build_offenders(&[complex], &[]);
let mi = offs.iter().find(|o| o.kind == "low_maintainability");
assert!(mi.is_some(), "a low-MI file with complex functions must still flag");
assert_eq!(mi.unwrap().severity, "error");
}
#[test]
fn offender_complexity_severity_split_at_20() {
let warn = build_offenders(&[], &[func_with(15)]);
assert_eq!(warn[0].kind, "complexity");
assert_eq!(warn[0].severity, "warn");
let err = build_offenders(&[], &[func_with(25)]);
assert_eq!(err[0].severity, "error");
assert!(build_offenders(&[], &[func_with(10)]).is_empty());
}
#[test]
fn offender_too_many_functions_and_untested() {
let offs = build_offenders(&[file_with(90.0, 10, 21, false)], &[]);
assert!(offs.iter().any(|o| o.kind == "too_many_functions" && o.severity == "warn"));
assert!(offs.iter().any(|o| o.kind == "untested" && o.severity == "info"));
let clean = build_offenders(&[file_with(90.0, 10, 20, true)], &[]);
assert!(clean.is_empty());
}
#[test]
fn offender_complexity_not_capped_all_over_threshold_surface() {
let n: usize = 18; let body: String = (0..24).map(|j| format!(" if x == {j}:\n x += 1\n")).collect();
let src: String = (0..n)
.map(|i| format!("def fn{i}(x):\n{body} return x\n"))
.collect::<Vec<_>>()
.join("\n");
let (_fm, fns) = analyze_py(&src);
assert_eq!(fns.len(), n, "all {n} functions must parse");
assert!(fns.iter().all(|f| f.complexity == 25), "each function is CC 25 (1 + 24 ifs)");
let offs = build_offenders(&[], &fns);
let complexity: Vec<&Offender> = offs.iter().filter(|o| o.kind == "complexity").collect();
assert_eq!(
complexity.len(),
n,
"expected {n} complexity offenders (was capped at 15), got {}",
complexity.len()
);
assert!(
complexity.iter().all(|o| o.severity == "error"),
"CC 25 > 20 => every complexity offender is severity error"
);
}
#[test]
fn fail_on_gate_exit_codes() {
assert_eq!(compute_exit_code(None, true, true), 0);
assert_eq!(compute_exit_code(Some("warn"), false, false), 0);
assert_eq!(compute_exit_code(Some("warn"), true, false), 1);
assert_eq!(compute_exit_code(Some("warn"), false, true), 1);
assert_eq!(compute_exit_code(Some("error"), true, false), 0);
assert_eq!(compute_exit_code(Some("error"), false, true), 1);
}
#[test]
fn analyzes_php_ruby_typescript_without_a_project() {
let php = "<?php\nfunction f($a){ if ($a && $a > 0) { return 1; } return 0; }\n";
let (fm, fns, _s) = measured(Lang::Php, php, "t.php", false);
assert_eq!(fm.functions, 1);
assert!(fns[0].complexity >= 3); assert!(fm.maintainability > 0.0 && fm.maintainability <= 100.0);
let rb = "def f(a)\n return 1 if a && a > 0\n 0\nend\n";
let (fm, _f, _s) = measured(Lang::Ruby, rb, "t.rb", false);
assert_eq!(fm.functions, 1);
let ts = "export const f = (a: number) => { if (a && a > 0) { return 1; } return 0; };\n";
let (fm, fns, _s) = measured(Lang::Ts, ts, "t.ts", false);
assert_eq!(fm.functions, 1, "top-level arrow function is counted");
assert!(fns[0].complexity >= 3);
}
#[test]
fn typescript_imports_are_counted_as_dep_count() {
let ts = "import { a } from './a';\nimport b from './b';\nconst x = () => a + b;\n";
let (fm, _f, specs) = measured(Lang::Ts, ts, "t.ts", false);
assert_eq!(fm.dep_count, 2);
let mut got = specs.clone();
got.sort();
assert_eq!(got, vec!["./a".to_string(), "./b".to_string()]);
assert_eq!(fm.coupling_efferent, 0, "resolved in pass 2, not here");
}
#[test]
fn php_inline_fully_qualified_references_are_dependencies() {
let php = "<?php\nnamespace Tina4;\nfunction boot() {\n \\Tina4\\DotEnv::load();\n $d = \\Tina4\\Database::create('x');\n return \\Tina4\\Database::create('y');\n}\n";
let (fm, _f, specs) = measured(Lang::Php, php, "App.php", false);
assert!(specs.iter().any(|s| s.contains("DotEnv")), "got {specs:?}");
assert!(specs.iter().any(|s| s.contains("Database")), "got {specs:?}");
assert_eq!(fm.dep_count, 2, "distinct dependencies, not reference count: {specs:?}");
let paths: HashSet<String> =
["DotEnv.php", "Database.php"].iter().map(|s| s.to_string()).collect();
assert_eq!(
resolve_import("\\Tina4\\DotEnv", "App.php", Lang::Php, &paths, Some("Tina4")),
Some("DotEnv.php".to_string())
);
}
#[test]
fn php_use_statements_and_requires_are_extracted() {
let php = "<?php\nnamespace Tina4;\nuse Tina4\\ORM;\nuse Tina4\\Database\\Database;\nrequire_once \"helpers.php\";\nfunction f($a) { return $a; }\n";
let (fm, _f, specs) = measured(Lang::Php, php, "Frond.php", false);
let mut got = specs.clone();
got.sort();
assert_eq!(
got,
vec![
"Tina4\\Database\\Database".to_string(),
"Tina4\\ORM".to_string(),
"helpers.php".to_string(),
]
);
assert_eq!(fm.dep_count, 3);
let paths: HashSet<String> = ["ORM.php", "Database/Database.php", "helpers.php"]
.iter().map(|s| s.to_string()).collect();
assert_eq!(
resolve_import("Tina4\\ORM", "Frond.php", Lang::Php, &paths, Some("Tina4")),
Some("ORM.php".to_string())
);
assert_eq!(
resolve_import("Tina4\\Database\\Database", "Frond.php", Lang::Php, &paths, Some("Tina4")),
Some("Database/Database.php".to_string())
);
}
#[test]
fn every_function_reports_its_own_loc() {
let py = "def small():\n return 1\n\ndef bigger(a):\n # comment line\n if a:\n return 2\n return 3\n";
let (_fm, fns) = analyze_py(py);
let small = fns.iter().find(|f| f.name == "small").unwrap();
let bigger = fns.iter().find(|f| f.name == "bigger").unwrap();
assert_eq!(small.loc, 2, "def + return");
assert_eq!(bigger.loc, 4, "comment excluded, same rule as file LOC");
assert!(bigger.loc > small.loc);
let json = serde_json::to_string(&bigger).unwrap();
assert!(json.contains("\"loc\""), "loc must be in the JSON: {json}");
}
#[test]
fn minified_and_bundled_assets_are_excluded_by_name() {
for n in [
"tina4.min.js", "frond.min.js", "app.min.ts", "site.min.css",
"vendor.bundle.js", "legacy-min.js", "app.js.map",
] {
assert!(is_generated_asset(Path::new(n)), "{n} should be excluded");
}
}
#[test]
fn real_source_files_are_not_mistaken_for_assets() {
for n in [
"engine.py", "server.ts", "Frond.php", "cli.rb",
"widget.js", "frond.js", "minify.js", "administer.ts",
] {
assert!(!is_generated_asset(Path::new(n)), "{n} must still be analysed");
}
}
#[test]
fn a_bundle_not_named_like_one_is_caught_by_content() {
let minified = format!("var a=1;{}\n", "b(c,d);".repeat(400));
assert!(looks_minified(&minified));
let real = "def handler(request, response):\n value = compute(request)\n return response(value)\n".repeat(50);
assert!(!looks_minified(&real), "normal source must not be skipped");
assert!(!looks_minified(""));
assert!(!looks_minified("x = 1\n"));
}
fn pathset(items: &[&str]) -> HashSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn python_relative_and_absolute_imports_resolve_to_files() {
let paths = pathset(&["frond/engine.py", "frond/parser.py", "debug/__init__.py", "env.py"]);
assert_eq!(
resolve_import(".parser", "frond/engine.py", Lang::Python, &paths, Some("tina4_python")),
Some("frond/parser.py".to_string())
);
assert_eq!(
resolve_import("debug", "env.py", Lang::Python, &paths, Some("tina4_python")),
Some("debug/__init__.py".to_string())
);
assert_eq!(
resolve_import("tina4_python.env", "frond/engine.py", Lang::Python, &paths, Some("tina4_python")),
Some("env.py".to_string())
);
}
#[test]
fn external_imports_are_not_internal_edges() {
let paths = pathset(&["env.py", "app/main.ts"]);
assert_eq!(resolve_import("os", "env.py", Lang::Python, &paths, None), None);
assert_eq!(resolve_import("json", "env.py", Lang::Python, &paths, None), None);
assert_eq!(resolve_import("react", "app/main.ts", Lang::Ts, &paths, None), None);
assert_eq!(resolve_import("node:fs", "app/main.ts", Lang::Ts, &paths, None), None);
}
#[test]
fn typescript_relative_specifier_resolves_including_js_to_ts() {
let paths = pathset(&["core/src/server.ts", "core/src/router.ts", "core/src/index.ts"]);
assert_eq!(
resolve_import("./router", "core/src/server.ts", Lang::Ts, &paths, None),
Some("core/src/router.ts".to_string())
);
assert_eq!(
resolve_import("./router.js", "core/src/server.ts", Lang::Ts, &paths, None),
Some("core/src/router.ts".to_string())
);
assert_eq!(
resolve_import("../src", "core/other/x.ts", Lang::Ts, &paths, None),
Some("core/src/index.ts".to_string())
);
}
#[test]
fn ruby_and_php_specifiers_resolve() {
let rb = pathset(&["lib/tina4/frond.rb", "lib/tina4/orm.rb"]);
assert_eq!(
resolve_import("orm", "lib/tina4/frond.rb", Lang::Ruby, &rb, None),
Some("lib/tina4/orm.rb".to_string())
);
assert_eq!(
resolve_import("tina4/orm", "app.rb", Lang::Ruby, &rb, None),
Some("lib/tina4/orm.rb".to_string())
);
let php = pathset(&["Tina4/Frond.php", "Tina4/ORM.php"]);
assert_eq!(
resolve_import("Tina4\\ORM", "Tina4/Frond.php", Lang::Php, &php, None),
Some("Tina4/ORM.php".to_string())
);
}
#[test]
fn a_file_never_couples_to_itself() {
let paths = pathset(&["a.py"]);
assert_eq!(resolve_import("a", "a.py", Lang::Python, &paths, None), None);
}
#[test]
fn instability_is_a_real_spread_not_a_constant() {
let dir = std::env::temp_dir().join(format!("tina4_coupling_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("leaf.py"), "def a():\n return 1\n").unwrap();
fs::write(dir.join("mid.py"), "import leaf\nimport os\ndef b():\n return leaf.a()\n").unwrap();
fs::write(dir.join("top.py"), "import leaf\nimport mid\ndef c():\n return mid.b()\n").unwrap();
let files = vec![dir.join("leaf.py"), dir.join("mid.py"), dir.join("top.py")];
let report = analyze_targets(&files, dir.to_str().unwrap());
let get = |n: &str| report.files.iter().find(|f| f.path == n).unwrap().clone();
let leaf = get("leaf.py");
assert_eq!(leaf.coupling_afferent, 2, "mid and top both import leaf");
assert_eq!(leaf.coupling_efferent, 0);
assert_eq!(leaf.instability, 0.0, "a pure dependency is maximally STABLE");
let top = get("top.py");
assert_eq!(top.coupling_afferent, 0, "nothing imports top");
assert_eq!(top.coupling_efferent, 2, "top imports leaf and mid");
assert_eq!(top.instability, 1.0, "a pure dependent is maximally UNSTABLE");
let mid = get("mid.py");
assert_eq!(mid.coupling_afferent, 1);
assert_eq!(mid.coupling_efferent, 1, "os is external and excluded");
assert_eq!(mid.instability, 0.5, "one in, one out");
assert_eq!(mid.dep_count, 2, "leaf + os");
let known: HashSet<&String> = report.files.iter().map(|f| &f.path).collect();
for (_from, targets) in report.dependency_graph.iter() {
for t in targets {
assert!(known.contains(t), "edge target {t} is not a scanned file");
}
}
let total_edges: usize = report.dependency_graph.values().map(|v| v.len()).sum();
assert_eq!(total_edges, 3, "leaf<-mid, leaf<-top, mid<-top");
let _ = fs::remove_dir_all(&dir);
}
fn scan_temp(tag: &str, files: &[(&str, &str)]) -> (Report, PathBuf) {
let dir = std::env::temp_dir()
.join(format!("tina4_dup_{tag}_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let mut paths = Vec::new();
for (name, body) in files {
let p = dir.join(name);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&p, body).unwrap();
paths.push(p);
}
let report = analyze_targets(&paths, dir.to_str().unwrap());
(report, dir)
}
fn dup_block(fn_name: &str, var: &str, op: &str) -> String {
format!(
"fn {fn_name}(input: i32) -> i32 {{
let mut {var} = 0;
for step in 0..input {{
if step % 2 == 0 {{
{var} = {var} {op} step;
}} else if step % 3 == 0 {{
{var} = {var} {op} (step * 2);
}} else {{
{var} = {var} {op} 1;
}}
}}
if {var} > 100 {{
{var} = 100;
}}
{var}
}}
"
)
}
#[test]
fn a_planted_duplicate_pair_is_found() {
let src = format!("{}\n{}", dup_block("alpha", "total", "+"), dup_block("beta", "total", "+"));
let (report, dir) = scan_temp("pair", &[("a.rs", &src)]);
assert!(
!report.clones.is_empty(),
"a planted identical pair must be reported as duplication"
);
let g = &report.clones[0];
assert!(g.copies >= 2, "expected at least 2 copies, got {}", g.copies);
assert!(
report.offenders.iter().any(|o| o.kind == "duplication"),
"the clone must surface as a `duplication` offender"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn changing_one_of_the_pair_stops_it_being_reported() {
let src = format!("{}\n{}", dup_block("alpha", "total", "+"), dup_block("beta", "total", "-"));
let (report, dir) = scan_temp("broken", &[("a.rs", &src)]);
assert!(
report.clones.is_empty(),
"changing an OPERATOR must break the match - shape hashing that ignored \
operator tokens would still call these duplicates: {:?}",
report.clones
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn renaming_identifiers_does_not_defeat_detection() {
let src = format!("{}\n{}", dup_block("alpha", "total", "+"), dup_block("gamma", "accumulator", "+"));
let (report, dir) = scan_temp("renamed", &[("a.rs", &src)]);
assert!(
!report.clones.is_empty(),
"a renamed copy is still a duplicate and must be found"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn duplication_is_detected_across_files() {
let a = dup_block("alpha", "total", "+");
let b = dup_block("beta", "total", "+");
let (report, dir) = scan_temp("xfile", &[("a.rs", &a), ("b.rs", &b)]);
let cross: Vec<&CloneGroup> = report.clones.iter().filter(|c| c.cross_file).collect();
assert!(
!cross.is_empty(),
"the same block in two different files must be reported as cross-file: {:?}",
report.clones
);
assert_eq!(cross[0].files.len(), 2, "both files must be named");
assert!(
report
.offenders
.iter()
.any(|o| o.kind == "duplication" && o.detail.contains("across 2 files")),
"the offender detail must say it spans files"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn duplication_negative_control_distinct_code_is_not_flagged() {
let src = "\
fn parse_port(raw: &str) -> Option<u16> {
raw.trim().parse::<u16>().ok()
}
fn banner(name: &str, version: &str) -> String {
format!(\"{name} v{version} ready\")
}
fn is_even(n: i64) -> bool {
n % 2 == 0
}
fn clamp_ratio(value: f64) -> f64 {
if value < 0.0 {
return 0.0;
}
if value > 1.0 {
return 1.0;
}
value
}
";
let (report, dir) = scan_temp("clean", &[("clean.rs", src)]);
assert!(
report.clones.is_empty(),
"structurally distinct functions must NOT be reported as duplicates: {:?}",
report.clones
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn trivial_repeated_accessors_are_below_the_reporting_floor() {
let src = "\
struct Config { host: String, port: String, user: String, pass: String, name: String }
impl Config {
fn host(&self) -> &str { &self.host }
fn port(&self) -> &str { &self.port }
fn user(&self) -> &str { &self.user }
fn pass(&self) -> &str { &self.pass }
fn name(&self) -> &str { &self.name }
}
";
let (report, dir) = scan_temp("getters", &[("cfg.rs", src)]);
assert!(
report.clones.is_empty(),
"five identical one-line getters must NOT be reported - they are \
identical by nature and unifying them would make the code worse: {:?}",
report.clones
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn duplication_works_for_every_language_not_just_rust() {
let py_block = |n: &str| format!(
"def {n}(items):\n total = 0\n for item in items:\n if item > 0:\n total += item\n elif item < 0:\n total -= item\n else:\n total += 1\n if total > 100:\n total = 100\n return total\n");
let php_block = |n: &str| format!(
"function {n}($items) {{\n $total = 0;\n foreach ($items as $item) {{\n if ($item > 0) {{\n $total += $item;\n }} elseif ($item < 0) {{\n $total -= $item;\n }} else {{\n $total += 1;\n }}\n }}\n if ($total > 100) {{\n $total = 100;\n }}\n return $total;\n}}\n");
let rb_block = |n: &str| format!(
"def {n}(items)\n total = 0\n items.each do |item|\n if item > 0\n total += item\n elsif item < 0\n total -= item\n else\n total += 1\n end\n end\n if total > 100\n total = 100\n end\n total\nend\n");
let ts_block = |n: &str| format!(
"export function {n}(items: number[]): number {{\n let total = 0;\n for (const item of items) {{\n if (item > 0) {{\n total += item;\n }} else if (item < 0) {{\n total -= item;\n }} else {{\n total += 1;\n }}\n }}\n if (total > 100) {{\n total = 100;\n }}\n return total;\n}}\n");
for (tag, name, body) in [
("py", "a.py", format!("{}\n{}", py_block("alpha"), py_block("beta"))),
("php", "a.php", format!("<?php\n{}\n{}", php_block("alpha"), php_block("beta"))),
("rb", "a.rb", format!("{}\n{}", rb_block("alpha"), rb_block("beta"))),
("ts", "a.ts", format!("{}\n{}", ts_block("alpha"), ts_block("beta"))),
] {
let (report, dir) = scan_temp(tag, &[(name, &body)]);
assert!(
!report.clones.is_empty(),
"{tag}: a planted duplicate pair must be found in EVERY language, \
not just Rust"
);
let _ = fs::remove_dir_all(dir);
}
}
#[test]
fn nested_copies_of_one_clone_are_reported_once() {
let src = format!("{}\n{}", dup_block("alpha", "total", "+"), dup_block("beta", "total", "+"));
let (report, dir) = scan_temp("nested", &[("a.rs", &src)]);
let at_same_place: Vec<&CloneGroup> = report
.clones
.iter()
.filter(|c| c.first_file == "a.rs" && c.first_line == report.clones[0].first_line)
.collect();
assert_eq!(
at_same_place.len(),
1,
"one duplicated region must yield ONE group, not one per nesting level: {:?}",
report.clones
);
let _ = fs::remove_dir_all(dir);
}
const HEALTHY: [(&str, &str, &str); 5] = [
(
"python", "ok.py",
"def classify(value, limit):\n if value is None:\n return 'none'\n if value > limit:\n return 'high'\n elif value < 0:\n return 'negative'\n return 'ok'\n\n\ndef total(rows):\n out = 0\n for row in rows:\n if row:\n out += row\n return out\n",
),
(
"php", "ok.php",
"<?php\nfunction classify($value, $limit) {\n if ($value === null) { return 'none'; }\n if ($value > $limit) { return 'high'; }\n elseif ($value < 0) { return 'negative'; }\n return 'ok';\n}\n\nfunction total($rows) {\n $out = 0;\n foreach ($rows as $row) {\n if ($row) { $out += $row; }\n }\n return $out;\n}\n",
),
(
"ruby", "ok.rb",
"def classify(value, limit)\n return 'none' if value.nil?\n return 'high' if value > limit\n return 'negative' if value < 0\n 'ok'\nend\n\ndef total(rows)\n out = 0\n rows.each do |row|\n out += row if row\n end\n out\nend\n",
),
(
"ts", "ok.ts",
"export function classify(value: number | null, limit: number): string {\n if (value === null) { return 'none'; }\n if (value > limit) { return 'high'; }\n else if (value < 0) { return 'negative'; }\n return 'ok';\n}\n\nexport function total(rows: number[]): number {\n let out = 0;\n for (const row of rows) {\n if (row) { out += row; }\n }\n return out;\n}\n",
),
(
"rust", "ok.rs",
"pub fn classify(value: Option<i32>, limit: i32) -> &'static str {\n let Some(v) = value else { return \"none\" };\n if v > limit {\n \"high\"\n } else if v < 0 {\n \"negative\"\n } else {\n \"ok\"\n }\n}\n\npub fn total(rows: &[i32]) -> i32 {\n let mut out = 0;\n for row in rows {\n if *row != 0 {\n out += row;\n }\n }\n out\n}\n",
),
];
#[test]
fn healthy_real_source_parses_at_full_health_in_every_language() {
for (name, _file, src) in HEALTHY {
let lang = match name {
"python" => Lang::Python,
"php" => Lang::Php,
"ruby" => Lang::Ruby,
"ts" => Lang::Ts,
_ => Lang::Rust,
};
let (fm, _f, _s) = measured(lang, src, "t", false);
assert_eq!(fm.parse_health, 1.0, "{name}: clean source must parse at 1.0");
assert!(
fm.parse_health >= MIN_PARSE_HEALTH,
"{name}: healthy source must never be refused"
);
}
}
#[test]
fn a_healthy_file_still_reports_full_metrics_in_every_language() {
for (name, file, src) in HEALTHY {
let (report, dir) = scan_temp(&format!("healthy_{name}"), &[(file, src)]);
assert_eq!(
report.refused.len(),
0,
"{name}: healthy source must NOT be refused - {:?}",
report.refused.iter().map(|r| &r.reason).collect::<Vec<_>>()
);
assert_eq!(report.files.len(), 1, "{name}: healthy source must be measured");
let fm = &report.files[0];
assert_eq!(fm.parse_health, 1.0, "{name}: health");
assert_eq!(fm.functions, 2, "{name}: both functions must be found");
assert!(fm.loc >= 10, "{name}: LOC must be real, got {}", fm.loc);
assert!(fm.complexity >= 4, "{name}: CC must count the branches, got {}", fm.complexity);
assert!(
fm.maintainability > 0.0,
"{name}: MI must be reported, got {}",
fm.maintainability
);
assert!(
!report.offenders.iter().any(|o| o.kind == "unparsed"),
"{name}: healthy source must raise no `unparsed` offender"
);
let _ = fs::remove_dir_all(dir);
}
}
#[test]
fn a_badly_broken_file_is_refused_in_every_language() {
let broken = [
("python", "py", "def f(:\n ??? not python at all ~~~\n <<<>>>\n ]]]}}}\n def ((\n"),
("php", "php", "<?php\nfunction ((( {{{ ]]] ???\n &&& ||| >>>\n class class class\n"),
("ruby", "rb", "def f(\n ??? ]]] }}}\n end end end end\n class class class\n"),
("ts", "ts", "function ((( {{{ ]]]\n ??? >>> <<<\n class class class\n ))) }}}\n"),
("rust", "rs", "fn f( { ]]] ???\n >>> <<< |||\n struct struct struct\n ))) }}}\n"),
];
for (name, ext, src) in broken {
let (report, dir) = scan_temp(
&format!("broken_{name}"),
&[(&format!("bad.{ext}"), src)],
);
assert_eq!(
report.files.len(),
0,
"{name}: an unparseable file must NOT appear in file_metrics - \
its numbers would poison every average"
);
assert_eq!(
report.refused.len(),
1,
"{name}: an unparseable file must be REFUSED, not silently skipped"
);
assert!(
report.refused[0].parse_health < MIN_PARSE_HEALTH,
"{name}: refused at health {}",
report.refused[0].parse_health
);
assert!(
report.offenders.iter().any(|o| o.kind == "unparsed"
&& o.detail.contains("NOT MEASURED")),
"{name}: refusal must surface as an `unparsed` offender"
);
let _ = fs::remove_dir_all(dir);
}
}
#[test]
fn a_refused_file_does_not_drag_down_a_healthy_scan() {
let good = "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n";
let (solo, d1) = scan_temp("solo_ok", &[("good.rs", good)]);
let (mixed, d2) = scan_temp(
"mixed_ok",
&[("good.rs", good), ("bad.rs", "fn f( { ]]] ???\n >>> <<< |||\n struct struct struct\n ))) }}}\n")],
);
assert_eq!(mixed.files.len(), 1, "only the healthy file is measured");
assert_eq!(mixed.refused.len(), 1);
assert_eq!(
solo.files[0].maintainability, mixed.files[0].maintainability,
"the healthy file's MI must be identical whether or not a broken \
file sat next to it"
);
let _ = fs::remove_dir_all(d1);
let _ = fs::remove_dir_all(d2);
}
#[test]
fn a_refused_file_contributes_no_duplication() {
let junk = "fn f( { ]]] ???\n >>> <<< |||\n struct struct struct\n ))) }}}\n ??? ]]] {{{\n <<< >>> |||\n fn fn fn fn\n";
let (report, dir) = scan_temp("junk_dup", &[("a.rs", junk), ("b.rs", junk)]);
assert_eq!(report.refused.len(), 2, "both are rubble");
assert!(
report.clones.is_empty(),
"two unparseable files must not be reported as duplicates of each \
other: {:?}",
report.clones
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn generated_doxygen_output_is_not_scanned_as_source() {
let dir = std::env::temp_dir().join(format!("tina4_doxy_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let gen = dir.join("docs/html");
fs::create_dir_all(&gen).unwrap();
fs::write(gen.join("doxygen.css"), "body{}\n").unwrap();
fs::write(gen.join("search.js"), "function search(a){ if(a){return 1;} return 0; }\n").unwrap();
let real = dir.join("docs/examples");
fs::create_dir_all(&real).unwrap();
fs::write(real.join("demo.js"), "function demo(a){ if(a){return 1;} return 0; }\n").unwrap();
let mut found = Vec::new();
walk_dir(&dir, &mut found);
let names: Vec<String> = found
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(
!names.contains(&"search.js".to_string()),
"generated Doxygen JS must not be scanned as source: {names:?}"
);
assert!(
names.contains(&"demo.js".to_string()),
"a real source file under docs/ must STILL be scanned - the marker \
file is the signal, not the directory name: {names:?}"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_refusal_is_loud_enough_to_fail_a_ci_gate() {
let (report, dir) = scan_temp(
"refusal_gate",
&[("bad.py", "def f(:\n ??? not python at all ~~~\n <<<>>>\n ]]]}}}\n def ((\n")],
);
let summary = build_summary(&report, report.offenders.len());
assert_eq!(summary.files_refused, 1, "the summary must count the refusal");
assert_eq!(summary.files_analyzed, 0);
let has_warn = report.offenders.iter().any(|o| o.severity == "warn");
let has_error = report.offenders.iter().any(|o| o.severity == "error");
assert!(has_warn, "a refusal must raise at least a warn");
assert_eq!(
compute_exit_code(Some("warn"), has_warn, has_error),
1,
"`--fail-on warn` must go red on a file the engine could not read"
);
assert_eq!(
compute_exit_code(Some("error"), has_warn, has_error),
0,
"`--fail-on error` must stay green - a grammar gap is not the \
author's error"
);
let _ = fs::remove_dir_all(dir);
}
fn deep_expression(terms: usize) -> String {
let mut src = String::from("x = (a0\n");
for i in 1..terms {
src.push_str(&format!(" + a{i}\n"));
}
src.push_str(")\n");
src
}
#[test]
fn a_pathologically_deep_file_is_refused_instead_of_aborting_the_scan() {
let src = deep_expression(MAX_AST_DEPTH + 200);
assert!(
!looks_minified(&src),
"the fixture must reach the guard, not be filtered as a bundle"
);
let good = "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n";
let (report, dir) = scan_temp("deep", &[("deep.py", &src), ("ok.rs", good)]);
assert_eq!(report.refused.len(), 1, "the deep file must be refused");
assert!(
report.refused[0].reason.contains("nests deeper"),
"the reason must name the real cause: {}",
report.refused[0].reason
);
assert_eq!(
report.files.len(),
1,
"the REST of the scan must survive - one bad file must not cost the \
whole report"
);
assert_eq!(report.files[0].path, "ok.rs");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_deeply_nested_but_survivable_file_is_still_measured() {
let src = deep_expression(600);
let (report, dir) = scan_temp("deep_ok", &[("deepish.py", &src)]);
assert_eq!(
report.refused.len(),
0,
"600 levels is under the {MAX_AST_DEPTH} limit and must be measured: {:?}",
report.refused.iter().map(|r| &r.reason).collect::<Vec<_>>()
);
assert_eq!(report.files.len(), 1);
assert!(report.files[0].loc > 0);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn depth_guard_boundary_is_exact() {
let mut parser = Parser::new();
parser.set_language(&Lang::Python.tree_sitter_language()).unwrap();
let tree = parser.parse(deep_expression(50), None).unwrap();
let root = tree.root_node();
let mut depth = 0usize;
let mut stack = vec![(root, 0usize)];
while let Some((node, d)) = stack.pop() {
if d > depth {
depth = d;
}
let mut c = node.walk();
for child in node.children(&mut c) {
stack.push((child, d + 1));
}
}
assert!(depth > 10, "the fixture must actually be deep, got {depth}");
assert!(!depth_exceeds(root, depth), "depth {depth} does not EXCEED {depth}");
assert!(
depth_exceeds(root, depth - 1),
"depth {depth} does exceed {}",
depth - 1
);
}
#[test]
fn an_error_region_never_becomes_a_clone_fragment() {
let junk = "class ][ oops @@@\n %%%% ????\n ]]] [[[ }}}\n &&& ||| ^^^\n ~~~ !!! @@@\n ((( ))) {{{\n ,,, ... ;;;\n";
let mut parser = Parser::new();
parser.set_language(&Lang::Python.tree_sitter_language()).unwrap();
let tree = parser.parse(junk, None).unwrap();
let mut qualifying_error = false;
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
let mut c = node.walk();
let children: Vec<Node> = node.children(&mut c).collect();
if node.is_error() {
let mut count = 0u32;
let mut inner = vec![node];
while let Some(n) = inner.pop() {
count += 1;
let mut ic = n.walk();
for ch in n.children(&mut ic) {
inner.push(ch);
}
}
let lines = node.end_position().row - node.start_position().row + 1;
if count >= MIN_CLONE_NODES && lines >= MIN_CLONE_LINES {
qualifying_error = true;
}
}
stack.extend(children);
}
assert!(
qualifying_error,
"fixture no longer contains an ERROR node big enough to clear both \
clone gates - it is not testing anything"
);
let mut fragments: Vec<CloneFragment> = Vec::new();
let (_hash, _count, clean) = collect_fragments(tree.root_node(), 0, &mut fragments);
assert!(!clean, "a tree with an ERROR node is not clean");
assert!(
fragments.is_empty(),
"no fragment may be hashed out of a parse error: {fragments:?}"
);
}
#[test]
fn two_differently_broken_files_are_never_duplicates_of_each_other() {
let a = "def f(:\n ??? not python ~~~\n <<<>>>\n ]]]}}}\n def ((\n class ][\n @@@ %%%\n";
let b = "class ][ oops @@@\n %%%% ????\n ]]] [[[ }}}\n &&& ||| ^^^\n ~~~ !!! @@@\n ((( ))) {{{\n ,,, ... ;;;\n";
let (report, dir) = scan_temp("two_broken", &[("a.py", a), ("b.py", b)]);
assert_eq!(report.refused.len(), 2, "both are rubble");
assert!(
report.clones.is_empty(),
"two DIFFERENT unparseable files must not be reported as duplicates: {:?}",
report.clones
);
assert!(
!report.offenders.iter().any(|o| o.kind == "duplication"),
"no duplication offender may come out of two misparses"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn comments_are_hashed_so_this_is_not_full_type_2() {
fn shape(lang: Lang, src: &str) -> u64 {
let mut parser = Parser::new();
parser.set_language(&lang.tree_sitter_language()).unwrap();
let tree = parser.parse(src, None).unwrap();
let mut out = Vec::new();
collect_fragments(tree.root_node(), 0, &mut out).0
}
assert_eq!(
shape(Lang::Python, "def a(x):\n return x + 1\n"),
shape(Lang::Python, "def b(y):\n return y + 1\n"),
"an identifier rename must NOT change the shape"
);
assert_eq!(
shape(Lang::Python, "def a(x):\n return x + 1\n"),
shape(Lang::Python, "def a(x):\n return x + 2\n"),
"a same-kind literal change must NOT change the shape"
);
assert_eq!(
shape(Lang::Python, "def a(x):\n return x + 1\n"),
shape(Lang::Python, "def a(x):\n return x + 1\n"),
"reformatting must NOT change the shape"
);
assert_ne!(
shape(Lang::Python, "def a(x):\n return x + 1\n"),
shape(Lang::Python, "def a(x):\n return x - 1\n"),
"`+` and `-` must not collide"
);
for (name, lang, plain, commented) in [
(
"python", Lang::Python,
"def a(x):\n return x + 1\n",
"def a(x):\n # explain\n return x + 1\n",
),
(
"python-docstring", Lang::Python,
"def a(x):\n return x + 1\n",
"def a(x):\n \"doc\"\n return x + 1\n",
),
(
"php", Lang::Php,
"<?php\nfunction a($x) { return $x + 1; }\n",
"<?php\n/** d */\nfunction a($x) { return $x + 1; }\n",
),
(
"ruby", Lang::Ruby,
"def a(x)\n x + 1\nend\n",
"# c\ndef a(x)\n x + 1\nend\n",
),
(
"ts", Lang::Ts,
"function a(x: number) { return x + 1; }\n",
"// c\nfunction a(x: number) { return x + 1; }\n",
),
(
"rust", Lang::Rust,
"fn a(x: i32) -> i32 { x + 1 }\n",
"/// doc\nfn a(x: i32) -> i32 { x + 1 }\n",
),
] {
assert_ne!(
shape(lang, plain),
shape(lang, commented),
"{name}: comments ARE hashed today. If this now matches, the \
engine has become comment-blind and the doc comment on the \
duplication section must be updated to say so - it is a \
change in which clones get reported, not a free win."
);
}
}
fn locate_tina4_python() -> Option<PathBuf> {
if let Ok(d) = std::env::var("TINA4_PYTHON_DIR") {
let p = PathBuf::from(d);
if p.is_dir() {
return Some(p);
}
}
let candidate = manifest().join("../tina4-python");
if candidate.join("tina4_python/dev_admin/metrics.py").is_file() {
return Some(candidate);
}
None
}
fn python_bin(dir: &Path) -> Option<PathBuf> {
let venv = dir.join(".venv/bin/python");
if venv.is_file() {
return Some(venv);
}
for name in ["python3", "python"] {
if let Ok(p) = which::which(name) {
return Some(p);
}
}
None
}
#[derive(serde::Deserialize)]
struct PyRef {
loc: usize,
complexity: u32,
functions: usize,
maintainability: f64,
avg_complexity: f64,
#[serde(default)]
error: Option<String>,
}
#[test]
fn parity_matches_python_master() {
let Some(dir) = locate_tina4_python() else {
eprintln!("SKIP parity: tina4-python not found (set TINA4_PYTHON_DIR)");
return;
};
let Some(py) = python_bin(&dir) else {
eprintln!("SKIP parity: no python interpreter available");
return;
};
let driver = manifest().join("tests/parity_reference.py");
for name in ["sample_container.py", "sample_metrics.py"] {
let fixture = manifest().join("tests/fixtures").join(name);
let out = Command::new(&py)
.arg(&driver)
.arg(&fixture)
.env("TINA4_PYTHON_DIR", &dir)
.output()
.expect("failed to run parity_reference.py");
let stdout = String::from_utf8_lossy(&out.stdout);
let reference: PyRef = serde_json::from_str(stdout.trim())
.unwrap_or_else(|_| panic!("bad driver output for {name}: {stdout}"));
if let Some(err) = &reference.error {
eprintln!("SKIP parity for {name}: {err}");
return;
}
let src = read_fixture(name);
let (fm, _fns) = analyze_py(&src);
eprintln!(
"PARITY {name}: py(loc={},cc={},fn={},mi={},avg={}) rust(loc={},cc={},fn={},mi={},avg={})",
reference.loc, reference.complexity, reference.functions,
reference.maintainability, reference.avg_complexity,
fm.loc, fm.complexity, fm.functions, fm.maintainability, fm.avg_complexity
);
assert_eq!(fm.loc, reference.loc, "{name}: LOC must match exactly");
assert_eq!(fm.complexity, reference.complexity, "{name}: total CC must match exactly");
assert_eq!(fm.functions, reference.functions, "{name}: function count must match exactly");
assert!(
(fm.avg_complexity - reference.avg_complexity).abs() <= 0.01,
"{name}: avg complexity {} vs {}", fm.avg_complexity, reference.avg_complexity
);
assert!(
(fm.maintainability - reference.maintainability).abs() <= 0.15,
"{name}: MI {} vs {}", fm.maintainability, reference.maintainability
);
}
}
#[test]
fn declared_type_names_finds_a_short_class() {
let names = declared_type_names("class ORM:\n def save(self):\n return True\n", Lang::Python);
assert!(names.contains(&"ORM".to_string()), "got {names:?}");
}
#[test]
fn a_three_char_class_referenced_by_a_test_counts_as_tested() {
let idx = TestIndex {
file_names: ["test_models.py".to_string()].into_iter().collect(),
contents: vec!["from src import ORM\n\ndef test_save():\n assert ORM().save()\n".to_string()],
};
let declared = vec!["ORM".to_string()];
assert!(
module_has_tests(Path::new("src/orm.py"), &idx, &declared),
"the ORM class symbol is the only signal here and it is a real one"
);
}
#[test]
fn an_unreferenced_class_is_still_untested() {
let idx = TestIndex {
file_names: ["test_other.py".to_string()].into_iter().collect(),
contents: vec!["def test_nothing():\n assert True\n".to_string()],
};
let declared = vec!["Widget".to_string()];
assert!(
!module_has_tests(Path::new("src/widget.py"), &idx, &declared),
"a class no test mentions must not be reported as tested"
);
}
#[test]
fn a_symbol_match_is_whole_identifier_only() {
assert!(mentions_symbol("assert ORM().save()", "ORM"));
assert!(mentions_symbol("from src import ORM", "ORM"));
assert!(!mentions_symbol("class ORMBase: pass", "ORM"));
assert!(!mentions_symbol("x = MyORM()", "ORM"));
assert!(!mentions_symbol("FORMAT = 1", "ORM"));
assert!(mentions_symbol("Order(1)", "Order"));
assert!(!mentions_symbol("OrderItem(1)", "Order"));
}
#[test]
fn a_typescript_interface_is_a_declared_type() {
let names = declared_type_names(
"export interface WidgetConnection {\n id: string;\n}\n", Lang::Ts);
assert!(names.contains(&"WidgetConnection".to_string()), "got {names:?}");
}
#[test]
fn a_typescript_interface_referenced_by_a_test_counts_as_tested() {
let idx = TestIndex {
file_names: ["widget.test.ts".to_string()].into_iter().collect(),
contents: vec!["const c: import(\"../src/widgetConnection.ts\").WidgetConnection = { id: \"1\" };".to_string()],
};
let declared = vec!["WidgetConnection".to_string()];
assert!(module_has_tests(Path::new("src/widgetConnection.ts"), &idx, &declared));
}
#[test]
fn an_unreferenced_interface_is_still_untested() {
let idx = TestIndex {
file_names: ["other.test.ts".to_string()].into_iter().collect(),
contents: vec!["const x = 1;".to_string()],
};
let declared = vec!["SoloIface".to_string()];
assert!(!module_has_tests(Path::new("src/ctrlIface.ts"), &idx, &declared));
}
#[test]
fn a_phpunit_pascalcase_test_file_counts_as_tested() {
let idx = TestIndex {
file_names: ["metricstest.php".to_string()].into_iter().collect(),
contents: vec!["<?php\nclass MetricsTest {}\n".to_string()],
};
assert!(
module_has_tests(Path::new("Tina4/Metrics.php"), &idx, &[]),
"MetricsTest.php is the dedicated test for Metrics.php"
);
}
#[test]
fn a_pascalcase_match_is_anchored_not_a_substring() {
let idx = TestIndex {
file_names: ["databasetest.php".to_string()].into_iter().collect(),
contents: vec!["<?php\nclass DatabaseTest {}\n".to_string()],
};
assert!(
!module_has_tests(Path::new("Tina4/Base.php"), &idx, &[]),
"DatabaseTest.php tests Database, not Base"
);
}
}