use crate::models::{Annotation, FunctionInfo};
use aho_corasick::AhoCorasick;
use regex::Regex;
use serde_json::json;
use std::collections::HashSet;
use std::path::Path;
use std::sync::OnceLock;
use tree_sitter::Node;
pub fn scope_qualify(module_path: &str, chain: &[String], name: &str) -> String {
let mut out = String::from(module_path);
for segment in chain {
out.push('.');
out.push_str(segment);
}
out.push('.');
out.push_str(name);
out
}
pub fn extend_scope_chain(chain: Option<&[String]>, name: &str) -> Option<Vec<String>> {
chain.map(|c| {
let mut next = Vec::with_capacity(c.len() + 1);
next.extend_from_slice(c);
next.push(name.to_string());
next
})
}
pub fn tag_scope(info: &mut FunctionInfo, module_path: &str, chain: &[String], depth: u32) {
if let Some((own, head)) = chain.split_last() {
info.metadata.insert(
"parent_scope".into(),
json!(scope_qualify(module_path, head, own)),
);
}
if depth > 0 {
info.metadata
.insert("nesting_depth".into(), json!(u64::from(depth)));
}
}
pub fn break_qualified_name_ties(functions: &mut [FunctionInfo]) {
let mut seen: HashSet<String> = HashSet::with_capacity(functions.len());
for function in functions.iter_mut() {
if !seen.insert(function.qualified_name.clone()) {
let nested = function
.metadata
.get("nesting_depth")
.and_then(|v| v.as_u64())
.is_some_and(|d| d > 0);
if nested {
function.qualified_name =
format!("{}#{}", function.qualified_name, function.line_number);
seen.insert(function.qualified_name.clone());
}
}
}
}
pub(super) fn file_to_module_path(filepath: &Path, src_root: &Path, separator: char) -> String {
let stem = filepath.file_stem().and_then(|o| o.to_str()).unwrap_or("");
let pkg = src_root.file_name().and_then(|o| o.to_str()).unwrap_or("");
let mut parts = Vec::new();
if !pkg.is_empty() {
parts.push(pkg.to_string());
}
if let Ok(relative) = filepath.strip_prefix(src_root) {
if let Some(parent) = relative.parent() {
parts.extend(parent.components().filter_map(|component| {
let value = component.as_os_str().to_str()?;
(!value.is_empty()).then(|| value.to_string())
}));
}
}
if !stem.is_empty() {
parts.push(stem.to_string());
}
parts.join(&separator.to_string())
}
pub(super) fn make_qualified(
module_path: &str,
owner_prefix: &str,
name: &str,
separator: char,
) -> String {
match (module_path.is_empty(), owner_prefix.is_empty()) {
(true, true) => name.to_string(),
(true, false) => format!("{owner_prefix}{separator}{name}"),
(false, true) => format!("{module_path}{separator}{name}"),
(false, false) => format!("{owner_prefix}{separator}{name}"),
}
}
fn has_annotation_keyword(source: &[u8]) -> bool {
static AC: OnceLock<AhoCorasick> = OnceLock::new();
let ac = AC.get_or_init(|| {
AhoCorasick::builder()
.ascii_case_insensitive(true)
.build([
"TODO", "FIXME", "HACK", "SAFETY", "XXX", "BUG", "NOTE", "WARNING",
])
.expect("aho-corasick patterns compile")
});
ac.is_match(source)
}
pub fn is_test_path(rel_path: &str, filename: &str, suffix_patterns: &[&str]) -> bool {
if suffix_patterns.iter().any(|s| filename.ends_with(s)) {
return true;
}
let normalised = rel_path.replace('\\', "/");
for segment in normalised.split('/') {
if matches!(segment, "test" | "tests" | "__tests__" | "spec" | "specs") {
return true;
}
}
false
}
#[inline]
pub fn node_text<'a>(node: Node<'a>, source: &'a [u8]) -> &'a str {
std::str::from_utf8(&source[node.byte_range()]).unwrap_or("")
}
pub fn count_lines(source: &[u8]) -> u32 {
let newlines = bytecount_newlines(source);
if !source.is_empty() && !source.ends_with(b"\n") {
newlines + 1
} else {
newlines
}
}
#[inline]
fn bytecount_newlines(src: &[u8]) -> u32 {
src.iter().filter(|&&b| b == b'\n').count() as u32
}
pub fn get_type_parameters(node: Node, source: &[u8], node_type: &str) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == node_type {
let text = node_text(child, source).trim();
let stripped = if let Some(inner) =
text.strip_prefix('<').and_then(|s| s.strip_suffix('>'))
{
inner.trim()
} else if let Some(inner) = text.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
inner.trim()
} else {
text
};
if stripped.is_empty() {
return None;
}
return Some(stripped.to_string());
}
}
None
}
fn annotation_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)^(TODO|FIXME|HACK|SAFETY|XXX|BUG|NOTE|WARNING)\b[:\s]*(.*)")
.expect("annotation regex compiles")
})
}
fn comment_line_payload(mut line: &str) -> &str {
line = line.trim();
loop {
let stripped = if let Some(rest) = line.strip_prefix("<!--") {
rest
} else if let Some(rest) = line.strip_prefix("/*") {
rest
} else if let Some(rest) = line.strip_prefix("//") {
rest
} else if let Some(rest) = line.strip_prefix('#') {
rest
} else if let Some(rest) = line.strip_prefix("--") {
rest
} else if let Some(rest) = line.strip_prefix(';') {
rest
} else if let Some(rest) = line.strip_prefix('*') {
rest
} else if let Some(rest) = line.strip_prefix('/') {
rest
} else if let Some(rest) = line.strip_prefix('!') {
rest
} else {
break;
};
line = stripped.trim_start();
}
line = line.trim_end();
for suffix in ["-->", "*/"] {
if let Some(rest) = line.strip_suffix(suffix) {
line = rest.trim_end();
break;
}
}
line
}
fn annotations_from_comment(text: &str, start_line: u32) -> Vec<Annotation> {
let re = annotation_regex();
text.lines()
.enumerate()
.filter_map(|(offset, line)| {
let payload = comment_line_payload(line);
let caps = re.captures(payload)?;
let marker = caps.get(1)?;
let marker_text = marker.as_str();
let suffix = &payload[marker.end()..];
if marker_text != marker_text.to_ascii_uppercase()
&& !suffix.trim_start().starts_with(':')
{
return None;
}
let kind = marker_text.to_ascii_uppercase();
let body = caps.get(2).map(|m| m.as_str().trim()).unwrap_or("");
Some(Annotation {
kind,
text: body.chars().take(200).collect(),
line: start_line + offset as u32,
})
})
.collect()
}
pub const DEFAULT_COMMENT_TYPES: &[&str] = &["line_comment", "block_comment", "comment"];
pub fn extract_comment_annotations(
root: Node,
source: &[u8],
comment_types: &[&str],
) -> Option<Vec<Annotation>> {
if !has_annotation_keyword(source) {
return None;
}
let mut out: Vec<Annotation> = Vec::new();
let mut stack: Vec<Node> = vec![root];
while let Some(node) = stack.pop() {
let kind = node.kind();
if comment_types.contains(&kind) {
let text = node_text(node, source);
out.extend(annotations_from_comment(
text,
node.start_position().row as u32 + 1,
));
}
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
for child in children.into_iter().rev() {
stack.push(child);
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
pub const BRANCH_KINDS_PYTHON: &[&str] = &[
"if_statement",
"elif_clause",
"while_statement",
"for_statement",
"except_clause",
"case_clause",
"conditional_expression", "boolean_operator", ];
pub const BRANCH_KINDS_RUST: &[&str] = &[
"if_expression",
"while_expression",
"while_let_expression",
"for_expression",
"loop_expression",
"match_arm",
"try_expression", ];
pub const BRANCH_KINDS_GO: &[&str] = &[
"if_statement",
"for_statement",
"expression_case",
"type_case",
"communication_case",
];
pub const BRANCH_KINDS_JAVA: &[&str] = &[
"if_statement",
"while_statement",
"for_statement",
"enhanced_for_statement",
"do_statement",
"switch_label",
"catch_clause",
"ternary_expression",
];
pub const BRANCH_KINDS_TS: &[&str] = &[
"if_statement",
"while_statement",
"for_statement",
"for_in_statement",
"for_of_statement",
"do_statement",
"switch_case",
"catch_clause",
"ternary_expression",
];
pub const BRANCH_KINDS_CPP: &[&str] = &[
"if_statement",
"while_statement",
"for_statement",
"for_range_loop",
"do_statement",
"case_statement",
"catch_clause",
"conditional_expression",
];
pub const BRANCH_KINDS_CSHARP: &[&str] = &[
"if_statement",
"while_statement",
"for_statement",
"for_each_statement",
"do_statement",
"switch_section",
"catch_clause",
"conditional_expression",
];
pub const BRANCH_KINDS_DART: &[&str] = &[
"if_statement",
"while_statement",
"for_statement",
"do_statement",
"switch_statement_case",
"switch_expression_case",
"catch_clause",
"conditional_expression",
"if_null_expression",
"logical_and_expression",
"logical_or_expression",
"if_element",
"for_element",
];
pub fn compute_complexity(
body: Node,
branch_kinds: &[&str],
nested_scope_kinds: &[&str],
) -> (u32, u32) {
let branch_set: HashSet<&str> = branch_kinds.iter().copied().collect();
let nested_set: HashSet<&str> = nested_scope_kinds.iter().copied().collect();
let mut count: u32 = 0;
let mut max_depth: u32 = 0;
walk(
body,
&branch_set,
&nested_set,
0,
&mut count,
&mut max_depth,
);
(count, max_depth)
}
fn walk(
node: Node,
branches: &HashSet<&str>,
nested: &HashSet<&str>,
depth: u32,
count: &mut u32,
max_depth: &mut u32,
) {
let kind = node.kind();
let is_branch = branches.contains(kind);
let next_depth = if is_branch {
*count += 1;
let d = depth + 1;
if d > *max_depth {
*max_depth = d;
}
d
} else {
depth
};
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if nested.contains(child.kind()) {
continue;
}
walk(child, branches, nested, next_depth, count, max_depth);
}
}
pub fn looks_like_macro_decorator(text: &str) -> bool {
if text.len() < 2 {
return false;
}
let mut chars = text.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_uppercase() || first == '_') {
return false;
}
text.chars()
.all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
}
fn procedure_annotation_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?m)^\s*@(?:cypher_)?procedure[:\s]+([a-zA-Z_][a-zA-Z0-9_]*)")
.expect("procedure annotation regex compiles")
})
}
pub fn extract_procedure_annotations(docstring: Option<&str>) -> Vec<String> {
let Some(text) = docstring else {
return Vec::new();
};
procedure_annotation_regex()
.captures_iter(text)
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
.collect()
}
fn generated_marker_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(concat!(
r"(?i)(",
r"\bcode\s+generated\b",
r"|\bdo\s+not\s+edit\b",
r"|\bautomatically\s+generated\b",
r"|<\s*auto-generated\s*>",
r"|@generated\b",
r"|\bgenerated\s+by\s+openapi",
r"|^\s*(?://|#|/\*|\*|--|<!--)\s*Generated\s+by\b",
r")",
))
.expect("generated marker regex compiles")
})
}
fn is_comment_line(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("//")
|| t.starts_with('#')
|| t.starts_with("/*")
|| t.starts_with('*')
|| t.starts_with("--")
|| t.starts_with("<!--")
|| t.starts_with(';')
}
pub fn is_generated_or_minified(source: &[u8]) -> Option<&'static str> {
if source.is_empty() {
return None;
}
let head_len = source.len().min(4096);
let head = &source[..head_len];
let head_str = match std::str::from_utf8(head) {
Ok(text) => text,
Err(error) => std::str::from_utf8(&head[..error.valid_up_to()]).unwrap_or(""),
};
let re = generated_marker_regex();
let mut non_empty_seen = 0;
for line in head_str.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
non_empty_seen += 1;
if non_empty_seen > 10 {
break;
}
if is_comment_line(line) && re.is_match(line) {
return Some("generated");
}
}
if source.len() >= 1024 {
let newline_count = source.iter().filter(|&&b| b == b'\n').count();
if newline_count == 0 {
return Some("minified");
}
}
let mut lines_seen: u32 = 0;
let mut total_width: u64 = 0;
for raw_line in source.split(|&b| b == b'\n') {
if raw_line.is_empty() {
continue;
}
total_width += raw_line.len() as u64;
lines_seen += 1;
if lines_seen >= 50 {
break;
}
}
if lines_seen >= 5 {
let avg = total_width / lines_seen as u64;
if avg > 500 {
return Some("minified");
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn annotations_require_a_leading_marker_and_keep_physical_lines() {
let comment = "/* This fixes a bug class.\n * TODO: wire the cache\n * warning users is ordinary prose\n * FIXME second item\n */";
let annotations = annotations_from_comment(comment, 20);
assert_eq!(annotations.len(), 2);
assert_eq!(annotations[0].kind, "TODO");
assert_eq!(annotations[0].text, "wire the cache");
assert_eq!(annotations[0].line, 21);
assert_eq!(annotations[1].kind, "FIXME");
assert_eq!(annotations[1].text, "second item");
assert_eq!(annotations[1].line, 23);
}
#[test]
fn annotations_support_common_comment_decorators_in_source_order() {
let comment = "/// NOTE: first\n//! SAFETY second\n<!-- HACK: third -->";
let annotations = annotations_from_comment(comment, 7);
let actual: Vec<(&str, &str, u32)> = annotations
.iter()
.map(|a| (a.kind.as_str(), a.text.as_str(), a.line))
.collect();
assert_eq!(
actual,
vec![
("NOTE", "first", 7),
("SAFETY", "second", 8),
("HACK", "third", 9),
]
);
}
#[test]
fn generated_banner_survives_a_split_utf8_boundary() {
let mut source = b"// Code generated by fixture; DO NOT EDIT.\n".to_vec();
source.resize(4095, b'a');
source.extend_from_slice("é".as_bytes());
source.push(b'\n');
assert_eq!(is_generated_or_minified(&source), Some("generated"));
}
#[test]
fn fallback_module_paths_keep_relative_directories_and_top_level_shape() {
let root = Path::new("/repo/site");
assert_eq!(
file_to_module_path(Path::new("/repo/site/index.html"), root, '.'),
"site.index"
);
assert_eq!(
file_to_module_path(Path::new("/repo/site/pages/index.html"), root, '.'),
"site.pages.index"
);
assert_eq!(
file_to_module_path(Path::new("/repo/site/pages/index.php"), root, '\\'),
"site\\pages\\index"
);
}
}