use std::borrow::Cow;
use crate::models::{EntityKind, ParsedEntity, ReferenceIntent};
use crate::pipeline::parser::comments::strip_comment_markers;
pub(crate) fn handle_groovy_capture(
capture_name: &str,
text: &str,
_node: tree_sitter::Node,
) -> Option<(String, EntityKind, usize)> {
let line = _node.start_position().row + 1;
match capture_name {
"groovy.class.name" => Some((text.to_string(), EntityKind::GroovyClass, line)),
"groovy.interface.name" => Some((text.to_string(), EntityKind::GroovyInterface, line)),
"groovy.enum.name" => Some((text.to_string(), EntityKind::GroovyEnum, line)),
"groovy.method.name" => Some((text.to_string(), EntityKind::GroovyMethod, line)),
"groovy.field.name" => Some((text.to_string(), EntityKind::GroovyProperty, line)),
_ => None,
}
}
#[expect(
clippy::too_many_lines,
reason = "function is verbose but correct — extraction deferred"
)]
#[expect(
clippy::cognitive_complexity,
reason = "function is verbose but correct — extraction deferred"
)]
pub(crate) fn extract_entities_groovy(
source: &str,
file_path: &str,
repo_name: &str,
) -> Vec<ParsedEntity> {
let mut entities: Vec<ParsedEntity> = vec![];
let package = extract_package(source);
let mut known_lines = std::collections::HashSet::new();
for entity in entities.iter_mut() {
known_lines.insert(entity.start_line);
if entity.kind == EntityKind::GroovyClass
&& let Some(line_content) = source.lines().nth(entity.start_line.saturating_sub(1))
{
let trimmed = line_content.trim();
if trimmed.starts_with("trait ") || trimmed.contains(" trait ") {
entity.kind = EntityKind::GroovyTrait;
}
}
if let Some(pkg) = &package {
entity.fqn = match entity.kind {
EntityKind::GroovyClass
| EntityKind::GroovyInterface
| EntityKind::GroovyTrait
| EntityKind::GroovyEnum => format!("{}.{}", pkg, entity.name),
_ => continue,
};
}
}
let mut scope_stack: Vec<(String, usize)> = Vec::new();
let mut brace_count = 0usize;
let mut in_block_comment = false;
let mut prop_decls: std::collections::HashMap<(String, String), GroovyPropertyDecl> =
std::collections::HashMap::new();
let lines: Vec<&str> = source.lines().collect();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx + 1;
let effective = strip_comments_line(line, &mut in_block_comment);
let opened = effective.matches('{').count();
let closed = effective.matches('}').count();
let prev_brace_count = brace_count;
brace_count += opened;
let mut early_pop = false;
if closed > opened {
let temp_brace = brace_count.saturating_sub(closed);
while let Some((_, entry_brace)) = scope_stack.last() {
if temp_brace < *entry_brace {
scope_stack.pop();
early_pop = true;
} else {
break;
}
}
}
if effective.is_empty() {
continue;
}
if !known_lines.contains(&line_num)
&& let Some((name, kind)) = try_extract_type_declaration(effective.as_ref())
{
let fqn = if let Some(pkg) = &package {
format!("{}.{}", pkg, name)
} else {
name.clone()
};
let current_brace = brace_count;
let decl_text =
build_type_declaration(source, line_idx).unwrap_or_else(|| effective.to_string());
let inheritance_intents = extract_inheritance_intents(&decl_text, &kind, line_num);
let docstring = extract_preceding_docstring(&lines, line_idx);
let mut new_entity = ParsedEntity::new(
&name, kind, &fqn, None, docstring, "groovy", file_path, line_num, line_num, None,
repo_name,
);
new_entity.reference_intents.extend(inheritance_intents);
entities.push(new_entity);
scope_stack.push((name, current_brace));
}
let enclosing = scope_stack.last().map(|(n, _)| n.clone());
if !known_lines.contains(&line_num) {
if let Some((method_name, signature)) = try_extract_def_method(effective.as_ref()) {
let fqn = build_fqn(&package, &enclosing, &method_name);
let docstring = extract_preceding_docstring(&lines, line_idx);
entities.push(ParsedEntity::new(
&method_name,
EntityKind::GroovyMethod,
&fqn,
Some(signature),
docstring,
"groovy",
file_path,
line_num,
line_num,
enclosing,
repo_name,
));
continue;
}
if let Some((method_name, _signature)) = try_extract_typed_method(effective.as_ref()) {
if method_name.contains('.')
|| method_name.chars().all(|c| c.is_uppercase() || c == '_')
{
continue;
}
let sig_end = effective.find('{').unwrap_or(effective.len());
let signature_full = effective[..sig_end].trim().to_string();
let fqn = build_fqn(&package, &enclosing, &method_name);
let docstring = extract_preceding_docstring(&lines, line_idx);
entities.push(ParsedEntity::new(
&method_name,
EntityKind::GroovyMethod,
&fqn,
Some(signature_full),
docstring,
"groovy",
file_path,
line_num,
line_num,
enclosing,
repo_name,
));
continue;
}
if let Some((method_name, method_start_line)) =
try_extract_typed_method_multiline(source, line_idx)
&& !method_name.contains('.')
{
let fqn = build_fqn(&package, &enclosing, &method_name);
let docstring = extract_preceding_docstring(&lines, method_start_line - 1);
entities.push(ParsedEntity::new(
&method_name,
EntityKind::GroovyMethod,
&fqn,
None,
docstring,
"groovy",
file_path,
method_start_line,
line_num,
enclosing,
repo_name,
));
continue;
}
}
let at_type_body_depth = scope_stack
.last()
.is_some_and(|(_, entry_brace)| brace_count == *entry_brace);
let at_script_level = scope_stack.is_empty() && prev_brace_count == 0;
if (at_type_body_depth || at_script_level)
&& !known_lines.contains(&line_num)
&& let Some(prop_decl) = try_extract_property(effective.as_ref())
{
let fqn = build_fqn(&package, &enclosing, &prop_decl.name);
let docstring = extract_preceding_docstring(&lines, line_idx);
let enclosing_for_prop = enclosing.clone();
let name_clone = prop_decl.name.clone();
let enc_clone = enclosing_for_prop.clone();
entities.push(ParsedEntity::new(
&prop_decl.name,
EntityKind::GroovyProperty,
&fqn,
None,
docstring,
"groovy",
file_path,
line_num,
line_num,
enclosing_for_prop,
repo_name,
));
if let Some(enc) = enc_clone {
prop_decls.insert((enc, name_clone), prop_decl);
}
}
brace_count = brace_count.saturating_sub(closed);
if !early_pop {
while let Some((_, entry_brace)) = scope_stack.last() {
if brace_count < *entry_brace {
scope_stack.pop();
} else {
break;
}
}
}
}
for entity in entities.iter_mut() {
if entity.kind == EntityKind::GroovyMethod
&& entity.end_line == entity.start_line
&& let Some(end_line) = find_method_body_end(source, entity.start_line)
&& end_line > entity.start_line
{
entity.end_line = end_line;
}
}
synthesize_property_accessors(&mut entities, &package, file_path, repo_name, &prop_decls);
let mut method_spans: Vec<(usize, usize, usize)> = entities
.iter()
.enumerate()
.filter(|(_, e)| {
matches!(
e.kind,
EntityKind::GroovyMethod | EntityKind::GroovyFunction
)
})
.map(|(i, e)| (e.start_line, e.end_line, i))
.collect();
method_spans.sort_by_key(|(s, _, _)| *s);
let refs = extract_method_calls(source, &entities);
for method_ref in refs.iter() {
if let ReferenceIntent::Call { line, .. } = method_ref {
let mut candidates: Vec<(usize, usize, usize)> = method_spans
.iter()
.filter(|(m_start, m_end, _)| {
let actual_end = if *m_end != *m_start { *m_end } else { *m_start };
*line > *m_start && *line <= actual_end
})
.copied()
.collect();
candidates.sort_by_key(|(s, e, _)| e.saturating_sub(*s));
if let Some(&(_, _, m_eidx)) = candidates.first() {
entities[m_eidx].reference_intents.push(method_ref.clone());
}
}
}
entities
}
#[expect(
clippy::too_many_lines,
reason = "function is verbose but correct — extraction deferred"
)]
fn synthesize_property_accessors(
entities: &mut Vec<ParsedEntity>,
package: &Option<String>,
file_path: &str,
repo_name: &str,
prop_decls: &std::collections::HashMap<(String, String), GroovyPropertyDecl>,
) {
use std::collections::{HashMap, HashSet};
let mut declared: HashSet<(String, String)> = HashSet::new(); let mut type_kind: HashMap<String, EntityKind> = HashMap::new();
for e in entities.iter() {
match e.kind {
EntityKind::GroovyClass
| EntityKind::GroovyInterface
| EntityKind::GroovyTrait
| EntityKind::GroovyEnum => {
type_kind.insert(e.name.clone(), e.kind.clone());
}
EntityKind::GroovyMethod => {
if let Some(ref cls) = e.enclosing_class {
declared.insert((cls.clone(), e.name.clone()));
}
}
_ => {}
}
}
let mut synthetic: Vec<ParsedEntity> = Vec::new();
for e in entities.iter() {
if e.kind != EntityKind::GroovyProperty {
continue;
}
let Some(ref cls) = e.enclosing_class else {
continue;
};
let Some(kind) = type_kind.get(cls.as_str()) else {
continue;
};
if *kind == EntityKind::GroovyInterface {
continue;
}
let prop_name = &e.name;
if prop_name.is_empty()
|| !prop_name
.as_bytes()
.first()
.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
{
continue;
}
if (prop_name.starts_with("get")
&& prop_name.chars().nth(3).is_some_and(|c| c.is_uppercase()))
|| (prop_name.starts_with("set")
&& prop_name.chars().nth(3).is_some_and(|c| c.is_uppercase()))
|| (prop_name.starts_with("is")
&& prop_name.chars().nth(2).is_some_and(|c| c.is_uppercase()))
{
continue;
}
let cap = {
let mut chars = prop_name.chars();
let first = chars.next().unwrap().to_uppercase().to_string();
let rest: String = chars.collect();
format!("{first}{rest}")
};
let decl_info = prop_decls.get(&(cls.clone(), e.name.clone()));
let getter_name = format!("get{cap}");
if !declared.contains(&(cls.clone(), getter_name.clone())) {
synthetic.push(make_synthetic_accessor(
&getter_name,
e,
package,
file_path,
repo_name,
cls,
));
}
if let Some(decl) = decl_info
&& let Some(ref dt) = decl.declared_type
&& (dt == "boolean" || dt == "Boolean")
{
let is_name = format!("is{cap}");
if !declared.contains(&(cls.clone(), is_name.clone())) {
synthetic.push(make_synthetic_accessor(
&is_name, e, package, file_path, repo_name, cls,
));
}
}
let is_final = decl_info.is_some_and(|d| d.is_final);
if !is_final {
let setter_name = format!("set{cap}");
if !declared.contains(&(cls.clone(), setter_name.clone())) {
synthetic.push(make_synthetic_accessor(
&setter_name,
e,
package,
file_path,
repo_name,
cls,
));
}
}
}
entities.append(&mut synthetic);
}
#[expect(
clippy::too_many_arguments,
reason = "function is verbose but correct — extraction deferred"
)]
fn make_synthetic_accessor(
name: &str,
property: &ParsedEntity,
package: &Option<String>,
file_path: &str,
repo_name: &str,
enclosing_class: &str,
) -> ParsedEntity {
let fqn = build_fqn(package, &Some(enclosing_class.to_string()), name);
ParsedEntity::new(
name,
EntityKind::GroovyMethod,
&fqn,
Some("<synthetic Groovy property accessor>".to_string()),
property.docstring.clone(),
"groovy",
file_path,
property.start_line,
property.end_line,
Some(enclosing_class.to_string()),
repo_name,
)
}
#[expect(
clippy::cognitive_complexity,
reason = "function is verbose but correct — extraction deferred"
)]
fn extract_method_calls(source: &str, _entities: &[ParsedEntity]) -> Vec<ReferenceIntent> {
let mut refs = Vec::new();
let keywords = [
"if",
"else",
"while",
"for",
"return",
"new",
"throw",
"catch",
"switch",
"case",
"import",
"package",
"class",
"interface",
"trait",
"enum",
"def",
"try",
"finally",
"assert",
"println",
"void",
"int",
"String",
"boolean",
"double",
"float",
"long",
"byte",
"short",
"char",
"public",
"private",
"protected",
"static",
"final",
"abstract",
"synchronized",
"volatile",
"transient",
];
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx + 1;
let trimmed = line.trim();
if trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with("*")
|| trimmed.starts_with("package ")
|| trimmed.starts_with("import ")
{
continue;
}
let mut chars = trimmed.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == '\"' || c == '\'' {
while let Some((_, nc)) = chars.next() {
if nc == c {
break; }
if nc == '\\' {
let _ = chars.next(); }
}
continue;
}
if !c.is_alphabetic() && c != '_' {
continue;
}
let word_start = i;
let mut word_end = i;
while let Some((_, nc)) = chars.peek() {
if nc.is_alphanumeric() || *nc == '_' {
word_end = chars.next().unwrap().0;
} else {
break;
}
}
let word = &trimmed[word_start..=word_end];
if keywords.contains(&word) {
continue;
}
let after_word = &trimmed[word_end + 1..];
let after_trimmed = after_word.trim_start();
if let Some(dot_rest) = after_trimmed.strip_prefix('.') {
let dot_trimmed = dot_rest.trim_start();
if let Some((next_word, rest)) = split_identifier(dot_trimmed) {
let after_next = rest.trim_start();
if after_next.starts_with('(') {
refs.push(ReferenceIntent::Call {
method: next_word.to_string(),
receiver: Some(word.to_string()),
line: line_num,
arg_count: None,
});
continue;
}
}
}
if after_trimmed.starts_with('(') && !keywords.contains(&word) && word.len() > 1 {
refs.push(ReferenceIntent::Call {
method: word.to_string(),
receiver: None,
line: line_num,
arg_count: None,
});
}
if !after_trimmed.is_empty()
&& !keywords.contains(&word)
&& word.len() > 1
&& !after_trimmed.starts_with('(')
&& !after_trimmed.starts_with('.')
&& !after_trimmed.starts_with('=')
&& !after_trimmed.starts_with('{')
&& !after_trimmed.starts_with(')')
&& !after_trimmed.starts_with(':')
&& !after_trimmed.starts_with(';')
{
let first_arg_char = after_trimmed.chars().next().unwrap();
if first_arg_char == '"'
|| first_arg_char == '\''
|| first_arg_char.is_alphabetic()
|| first_arg_char == '$'
{
refs.push(ReferenceIntent::Call {
method: word.to_string(),
receiver: None,
line: line_num,
arg_count: None,
});
}
}
}
}
refs
}
fn split_identifier(s: &str) -> Option<(&str, &str)> {
let first = s.chars().next()?;
if !first.is_alphabetic() && first != '_' {
return None;
}
let end = s
.find(|c: char| !c.is_alphanumeric() && c != '_')
.unwrap_or(s.len());
Some((&s[..end], &s[end..]))
}
#[expect(
clippy::cognitive_complexity,
reason = "function is verbose but correct — extraction deferred"
)]
fn strip_comments_line<'a>(line: &'a str, in_block: &mut bool) -> Cow<'a, str> {
let trimmed = line.trim();
if !*in_block && !trimmed.contains('/') && !trimmed.contains('*') {
return Cow::Borrowed(trimmed);
}
if *in_block {
if let Some(end_idx) = trimmed.find("*/") {
*in_block = false;
let rest = trimmed[end_idx + 2..].to_string();
if rest.trim().is_empty() {
return Cow::Owned(String::new());
}
return Cow::Owned(rest);
}
return Cow::Owned(String::new());
}
let mut result = String::with_capacity(trimmed.len());
let mut chars = trimmed.char_indices().peekable();
while let Some((_i, c)) = chars.next() {
if c == '/'
&& let Some(&(_, next)) = chars.peek()
{
if next == '/' {
let effective = result.trim_end().to_string();
return if effective.is_empty() {
Cow::Owned(String::new())
} else {
Cow::Owned(effective)
};
}
if next == '*' {
chars.next(); let mut found_close = false;
while let Some((_, c2)) = chars.next() {
if c2 == '*'
&& let Some(&(_, '/')) = chars.peek()
{
chars.next(); found_close = true;
break;
}
}
if !found_close {
*in_block = true;
let effective = result.trim_end().to_string();
return if effective.is_empty() {
Cow::Owned(String::new())
} else {
Cow::Owned(effective)
};
}
continue;
}
}
if c == '"' || c == '\'' {
let quote = c;
result.push(quote);
while let Some((_, c2)) = chars.next() {
result.push(c2);
if c2 == '\\' {
if let Some((_, esc)) = chars.next() {
result.push(esc);
}
} else if c2 == quote {
break;
}
}
continue;
}
result.push(c);
}
let effective = result.trim().to_string();
if effective.is_empty() {
Cow::Owned(String::new())
} else {
Cow::Owned(effective)
}
}
fn extract_package(source: &str) -> Option<String> {
for line in source.lines().take(20) {
let trimmed = line.trim();
if let Some(pkg) = trimmed.strip_prefix("package ") {
let name = pkg.trim().trim_end_matches(';').trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
None
}
fn build_fqn(package: &Option<String>, parent: &Option<String>, name: &str) -> String {
match (package, parent) {
(Some(pkg), Some(enclosing_class)) => format!("{}.{}.{}", pkg, enclosing_class, name),
(Some(pkg), None) => format!("{}.{}", pkg, name),
(None, Some(enclosing_class)) => format!("{}.{}", enclosing_class, name),
(None, None) => name.to_string(),
}
}
fn extract_preceding_docstring(lines: &[&str], decl_line_idx: usize) -> Option<String> {
let non_empty = |cleaned: String| (!cleaned.trim().is_empty()).then_some(cleaned);
let mut idx = decl_line_idx;
let mut blank_seen = false;
while idx > 0 {
let prev = lines[idx - 1].trim();
if prev.starts_with('@') {
idx -= 1;
continue;
}
if prev.is_empty() && !blank_seen {
blank_seen = true;
idx -= 1;
continue;
}
break;
}
if idx == 0 {
return None;
}
let last = lines[idx - 1].trim();
if last.ends_with("*/") {
if last.starts_with("/*") {
return non_empty(strip_comment_markers(last));
}
if !last.starts_with('*') {
return None;
}
let mut block: Vec<&str> = vec![lines[idx - 1]];
let mut j = idx - 1;
while j > 0 {
j -= 1;
let t = lines[j].trim();
if t.starts_with("/*") {
block.push(lines[j]);
block.reverse();
return non_empty(strip_comment_markers(&block.join("\n")));
}
if t.starts_with('*') {
block.push(lines[j]);
continue;
}
return None;
}
return None;
}
if last.starts_with("//") {
let mut j = idx - 1;
let mut burst: Vec<&str> = Vec::new();
loop {
if !lines[j].trim().starts_with("//") {
break;
}
burst.push(lines[j]);
if j == 0 {
break;
}
j -= 1;
}
burst.reverse();
return non_empty(strip_comment_markers(&burst.join("\n")));
}
None
}
fn find_method_body_end(source: &str, line_num: usize) -> Option<usize> {
let mut chars = source.chars().peekable();
let mut current_line = 1usize;
let mut brace_depth = 0i32;
let mut found_opening = false;
while current_line < line_num {
match chars.next() {
Some('\n') => current_line += 1,
Some(_) => {}
None => return None,
}
}
while let Some(ch) = chars.next() {
match ch {
'\n' => current_line += 1,
'/' if chars.peek() == Some(&'/') => {
for c in chars.by_ref() {
if c == '\n' {
current_line += 1;
break;
}
}
}
'"' | '\'' => {
let quote = ch;
while let Some(c) = chars.next() {
if c == '\\' {
let _ = chars.next();
} else if c == quote {
break;
}
}
}
'{' => {
brace_depth += 1;
found_opening = true;
}
'}' => {
brace_depth -= 1;
if found_opening && brace_depth == 0 {
return Some(current_line);
}
}
_ => {}
}
}
None
}
fn try_extract_type_declaration(line: &str) -> Option<(String, EntityKind)> {
let tokens: Vec<&str> = line.split_whitespace().collect();
for (i, token) in tokens.iter().enumerate() {
let kind = match *token {
"class" => EntityKind::GroovyClass,
"interface" => EntityKind::GroovyInterface,
"trait" => EntityKind::GroovyTrait,
"enum" => EntityKind::GroovyEnum,
_ => continue,
};
if i + 1 < tokens.len() {
let name_raw = tokens[i + 1];
let name = name_raw
.split('<')
.next()
.unwrap_or(name_raw)
.split('{')
.next()
.unwrap_or(name_raw)
.trim();
if !name.is_empty() && name.chars().next().unwrap().is_alphabetic() {
return Some((name.to_string(), kind));
}
}
}
None
}
fn build_type_declaration(source: &str, line_idx: usize) -> Option<String> {
const MAX_LOOKAHEAD: usize = 5;
let lines: Vec<&str> = source.lines().collect();
let mut buf = String::new();
for offset in 0..MAX_LOOKAHEAD {
let raw = lines.get(line_idx + offset)?.trim();
if raw.is_empty() {
buf.push(' ');
continue;
}
if raw.starts_with("//") || raw.starts_with("/*") || raw.starts_with("* ") || raw == "*" {
continue;
}
if !buf.is_empty() {
buf.push(' ');
}
buf.push_str(raw);
if raw.contains('{') {
return Some(buf);
}
}
None
}
fn strip_balanced_generics(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut depth: i32 = 0;
for ch in input.chars() {
match ch {
'<' => depth += 1,
'>' if depth > 0 => depth -= 1,
'>' => {} _ if depth == 0 => out.push(ch),
_ => {} }
}
out
}
fn is_valid_type_name(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_alphabetic() && first != '_' {
return false;
}
chars.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
}
pub(crate) fn extract_inheritance_intents(
decl: &str,
kind: &EntityKind,
line: usize,
) -> Vec<ReferenceIntent> {
let stripped = strip_balanced_generics(decl);
let mut intents = Vec::new();
let tokens: Vec<&str> = stripped.split_whitespace().collect();
let extends_idx = tokens.iter().position(|t| *t == "extends");
let implements_idx = tokens.iter().position(|t| *t == "implements");
if let Some(idx) = extends_idx {
let from = idx + 1;
let to = implements_idx.unwrap_or(tokens.len());
let parents: Vec<&str> = tokens[from..to]
.iter()
.copied()
.flat_map(|t| t.split(','))
.map(str::trim)
.filter(|t| is_valid_type_name(t))
.collect();
match kind {
EntityKind::GroovyInterface => {
for parent in parents {
intents.push(ReferenceIntent::Extends {
parent: parent.to_string(),
line,
});
}
}
_ => {
if let Some(first) = parents.into_iter().next() {
intents.push(ReferenceIntent::Extends {
parent: first.to_string(),
line,
});
}
}
}
}
if let Some(idx) = implements_idx {
let from = idx + 1;
for tok in &tokens[from..] {
if tok.contains('{') {
break;
}
for piece in tok.split(',') {
let trimmed = piece.trim();
if trimmed.is_empty() {
continue;
}
if is_valid_type_name(trimmed) {
intents.push(ReferenceIntent::Implements {
interface: trimmed.to_string(),
line,
});
}
}
}
}
intents
}
#[derive(Debug, Clone)]
struct GroovyPropertyDecl {
name: String,
declared_type: Option<String>,
is_final: bool,
}
#[expect(
clippy::too_many_lines,
reason = "function is verbose but correct — extraction deferred"
)]
fn try_extract_property(line: &str) -> Option<GroovyPropertyDecl> {
let mut cleaned = line.trim().trim_end_matches(';').trim().to_string();
loop {
let trimmed = cleaned.trim_start();
if let Some(rest) = trimmed.strip_prefix('@') {
let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
cleaned = rest[end..].trim().to_string();
} else {
break;
}
}
if cleaned.is_empty() {
return None;
}
if cleaned.starts_with("//") || cleaned.starts_with("/*") || cleaned.starts_with('*') {
return None;
}
let rejection_keywords = [
"return",
"import",
"package",
"class",
"interface",
"trait",
"enum",
"new",
"throw",
"assert",
"case",
"else",
"extends",
"implements",
"instanceof",
];
if let Some(eq_idx) = cleaned.find('=') {
if cleaned.chars().nth(eq_idx + 1) == Some('=') {
return None;
}
let left_side = cleaned[..eq_idx].trim();
if left_side.is_empty() {
return None;
}
let tokens: Vec<&str> = left_side.split_whitespace().collect();
if tokens.len() >= 2 {
let name = tokens.last().unwrap();
if is_valid_identifier(name) {
let first_token = tokens[0];
let declared_type = if first_token == "def" {
if tokens.len() >= 2 {
Some(tokens[tokens.len() - 2].to_string())
} else {
None
}
} else if is_valid_type_name(first_token) {
Some(first_token.to_string())
} else {
tokens
.iter()
.find(|t| is_valid_type_name(t))
.map(|t| t.to_string())
};
let is_final = tokens.contains(&"final");
return Some(GroovyPropertyDecl {
name: name.to_string(),
declared_type,
is_final,
});
}
}
return None;
}
if cleaned.contains('(')
|| cleaned.contains(')')
|| cleaned.contains('{')
|| cleaned.contains('}')
{
return None;
}
let tokens: Vec<&str> = cleaned.split_whitespace().collect();
if tokens.is_empty() || tokens.len() < 2 {
return None;
}
let first_token = tokens[0];
if rejection_keywords.contains(&first_token) {
return None;
}
let modifiers: &[&str] = &[
"private",
"protected",
"public",
"static",
"final",
"transient",
"volatile",
"synchronized",
"abstract",
"native",
];
let non_modifiers: Vec<&&str> = tokens.iter().filter(|t| !modifiers.contains(t)).collect();
if non_modifiers.len() < 2 {
return None;
}
let name = tokens.last().unwrap();
if !is_valid_identifier(name) {
return None;
}
let type_token = if tokens.len() >= 2 {
let candidate = tokens[tokens.len() - 2];
let candidate_stripped = strip_balanced_generics(candidate);
if candidate == "def" || is_valid_type_name(&candidate_stripped) {
Some(candidate.to_string())
} else if modifiers.contains(&candidate) {
tokens[..tokens.len() - 1]
.iter()
.rev()
.find(|t| {
!modifiers.contains(t)
&& **t != "def"
&& is_valid_type_name(&strip_balanced_generics(t))
})
.map(|t| t.to_string())
} else {
None
}
} else {
None
};
type_token.as_ref()?;
let is_final = tokens.contains(&"final");
Some(GroovyPropertyDecl {
name: name.to_string(),
declared_type: type_token,
is_final,
})
}
fn is_valid_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_alphabetic() && first != '_' {
return false;
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
#[expect(
clippy::too_many_lines,
reason = "function is verbose but correct — extraction deferred"
)]
fn try_extract_typed_method_multiline(source: &str, line_idx: usize) -> Option<(String, usize)> {
let lines: Vec<&str> = source.lines().collect();
let start_line = lines.get(line_idx)?;
let trimmed = start_line.trim();
let method_start_keywords = [
"private",
"public",
"protected",
"static",
"final",
"abstract",
"synchronized",
"volatile",
"transient",
"native",
"void",
"boolean",
"byte",
"short",
"int",
"long",
"float",
"double",
"char",
"String",
"Object",
"List",
"Map",
"Set",
"Closure",
"SimpleHttpServer",
];
if trimmed.starts_with("if ")
|| trimmed.starts_with("while ")
|| trimmed.starts_with("for ")
|| trimmed.starts_with("catch ")
|| trimmed.starts_with("switch ")
|| trimmed.starts_with("return ")
{
return None;
}
if !trimmed.contains('(') || trimmed.contains(')') {
return None;
}
let paren_idx = trimmed.find('(').unwrap();
if trimmed[..paren_idx].contains('=') {
return None;
}
let before_paren = trimmed[..paren_idx].trim();
let tokens: Vec<&str> = before_paren.split_whitespace().collect();
if tokens.len() < 2 {
return None;
}
let has_modifier = tokens.iter().any(|t| method_start_keywords.contains(t));
if !has_modifier {
if tokens.len() >= 2 {
let second_last = tokens[tokens.len() - 2];
if !second_last.chars().next().is_some_and(|c| c.is_uppercase()) {
return None;
}
} else {
return None;
}
}
let name = tokens.last().unwrap();
let first_char = name.chars().next()?;
if !first_char.is_alphabetic() && first_char != '_' {
return None;
}
let max_lookahead = 10;
let mut found_close_paren = false;
for offset in 1..=max_lookahead {
let next_line = lines.get(line_idx + offset)?;
let next_trimmed = next_line.trim();
if !found_close_paren && next_trimmed.contains(')') {
found_close_paren = true;
}
if next_trimmed.contains('{') {
if found_close_paren {
return Some((name.to_string(), line_idx + 1));
}
}
if next_trimmed.is_empty()
|| next_trimmed.starts_with("//")
|| next_trimmed.starts_with("/*")
{
continue;
}
}
None
}
fn try_extract_typed_method(line: &str) -> Option<(String, String)> {
if line.contains('(') && line.contains(')') && (line.contains('{') || line.ends_with(')')) {
if line.starts_with("if ")
|| line.starts_with("while ")
|| line.starts_with("for ")
|| line.starts_with("catch ")
|| line.starts_with("switch ")
{
return None;
}
let paren_idx = line.find('(').unwrap();
if line[..paren_idx].contains('=') {
return None;
}
if line[..paren_idx].contains("new ") || line[..paren_idx].ends_with(" new") {
return None;
}
let before_paren = line[..paren_idx].trim();
if let Some(quote_idx) = before_paren.find('\"') {
if let Some(close_idx) = before_paren[quote_idx + 1..].find('\"') {
let inner_name = &before_paren[quote_idx + 1..quote_idx + 1 + close_idx];
let sig_end = line.find('{').unwrap_or(line.len());
let signature = line[..sig_end].trim().to_string();
return Some((inner_name.to_string(), signature));
}
}
let tokens: Vec<&str> = before_paren.split_whitespace().collect();
if tokens.len() >= 2 {
let name = tokens.last().unwrap();
let first_char = name.chars().next().unwrap();
if first_char.is_alphabetic() || first_char == '_' {
let sig_end = line.find('{').unwrap_or(line.len());
let signature = line[..sig_end].trim().to_string();
return Some((name.to_string(), signature));
}
}
}
None
}
fn try_extract_def_method(line: &str) -> Option<(String, String)> {
if let Some(def_idx) = line.find("def ") {
if def_idx > 0 {
let prev_char = line.as_bytes()[def_idx - 1] as char;
if prev_char.is_alphanumeric() || prev_char == '_' {
return None;
}
}
let after_def = &line[def_idx + 4..].trim_start();
if let Some(paren_idx) = after_def.find('(') {
let potential_name = &after_def[..paren_idx].trim();
if !potential_name.is_empty() && !potential_name.contains(|c: char| c.is_whitespace()) {
let first_char = potential_name.chars().next().unwrap();
if first_char.is_alphabetic() || first_char == '_' {
let sig_end = line[def_idx..]
.find('{')
.map(|i| i + def_idx)
.unwrap_or(line.len());
let signature = line[def_idx..sig_end].trim().to_string();
return Some((potential_name.to_string(), signature));
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::parser::test_utils::{
assert_extends, assert_implements, collect_extends, collect_implements,
};
fn pick_class<'a>(entities: &'a [ParsedEntity], name: &str) -> &'a ParsedEntity {
entities
.iter()
.find(|e| e.name == name && e.kind == EntityKind::GroovyClass)
.unwrap_or_else(|| panic!("Groovy class '{name}' not found in entities"))
}
fn pick_entity<'a>(
entities: &'a [ParsedEntity],
name: &str,
kind: EntityKind,
) -> &'a ParsedEntity {
entities
.iter()
.find(|e| e.name == name && e.kind == kind)
.unwrap_or_else(|| {
panic!(
"Entity '{name}' ({kind:?}) not found in entities. Available: {:?}",
entities
.iter()
.map(|e| (&e.name, &e.kind))
.collect::<Vec<_>>()
)
})
}
#[test]
fn test_groovy_class_extraction() {
let source = "class MyGroovyClass { def method() {} }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "MyGroovyClass" && e.kind == EntityKind::GroovyClass)
);
}
#[test]
fn test_groovy_interface_extraction() {
let source = "interface MyGroovyInterface { void doIt() }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "MyGroovyInterface" && e.kind == EntityKind::GroovyInterface)
);
}
#[test]
fn test_groovy_enum_extraction() {
let source = "enum Color { RED, GREEN, BLUE }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "Color" && e.kind == EntityKind::GroovyEnum)
);
}
#[test]
fn test_groovy_method_extraction() {
let source = "class Foo { String greet(String name) { return name } }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let method = entities.iter().find(|e| e.name == "greet");
assert!(method.is_some(), "Expected method 'greet' to be extracted");
assert_eq!(method.unwrap().kind, EntityKind::GroovyMethod);
}
#[test]
fn test_groovy_trait_extraction() {
let source = "trait MyTrait { void doSomething() {} }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "MyTrait" && e.kind == EntityKind::GroovyTrait)
);
}
#[test]
fn test_groovy_property_extraction() {
let source = "class Foo { String name = 'test' }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "name" && e.kind == EntityKind::GroovyProperty)
);
}
#[test]
fn test_groovy_multiple_classes() {
let source = "package com.example\nclass First {}\nclass Second {}\nclass Third {}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let class_names: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::GroovyClass)
.map(|e| e.name.clone())
.collect();
assert!(class_names.contains(&"First".to_string()));
assert!(class_names.contains(&"Second".to_string()));
assert!(class_names.contains(&"Third".to_string()));
}
#[test]
fn test_groovy_constructor_extraction() {
let source = "class User { User(String name) { this.name = name } }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "User" && e.kind == EntityKind::GroovyMethod)
);
}
#[test]
fn test_groovy_empty_body_class() {
let source = "class EmptyClass {}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "EmptyClass" && e.kind == EntityKind::GroovyClass)
);
}
#[test]
fn test_groovy_method_in_class_extracts_correctly() {
let source = "class Calculator {\n int add(int a, int b) { return a + b }\n int subtract(int a, int b) { return a - b }\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "add" && e.kind == EntityKind::GroovyMethod)
);
assert!(
entities
.iter()
.any(|e| e.name == "subtract" && e.kind == EntityKind::GroovyMethod)
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "function is verbose but correct — extraction deferred"
)]
#[expect(
clippy::cognitive_complexity,
reason = "function is verbose but correct — extraction deferred"
)]
fn test_groovy_parse_sample_full_file() {
let source = include_str!("../../../../tests/testing_files/sample_full.groovy");
let entities = extract_entities_groovy(source, "sample_full.groovy", "test-repo");
println!("--- Extracted Entities ---");
for e in &entities {
println!("{:?} - {}", e.kind, e.name);
}
println!("--------------------------");
assert!(
entities
.iter()
.any(|e| e.name == "UserService" && e.kind == EntityKind::GroovyClass)
);
assert!(
entities
.iter()
.any(|e| e.name == "BaseService" && e.kind == EntityKind::GroovyClass)
);
assert!(
entities
.iter()
.any(|e| e.name == "DatabaseConfig" && e.kind == EntityKind::GroovyClass)
);
assert!(
entities
.iter()
.any(|e| e.name == "Repository" && e.kind == EntityKind::GroovyInterface)
);
assert!(
entities
.iter()
.any(|e| e.name == "Auditable" && e.kind == EntityKind::GroovyTrait)
);
assert!(
entities
.iter()
.any(|e| e.name == "Status" && e.kind == EntityKind::GroovyEnum)
);
assert!(
entities
.iter()
.any(|e| e.name == "scriptMethod" && e.kind == EntityKind::GroovyMethod)
);
assert!(
entities
.iter()
.any(|e| e.name == "anotherScriptMethod" && e.kind == EntityKind::GroovyMethod)
);
assert!(
entities
.iter()
.any(|e| e.name == "globalConfig" && e.kind == EntityKind::GroovyProperty)
);
assert!(
entities
.iter()
.any(|e| e.name == "processDataClosure" && e.kind == EntityKind::GroovyProperty)
);
assert!(
entities
.iter()
.any(|e| e.name == "initialize" && e.kind == EntityKind::GroovyMethod)
);
assert!(
entities
.iter()
.any(|e| e.name == "calculateTotal" && e.kind == EntityKind::GroovyMethod)
);
assert!(
entities
.iter()
.any(|e| e.name == "logAction" && e.kind == EntityKind::GroovyMethod)
);
assert!(entities.iter().any(|e| e.name
== "addition of #num1 and #num2 should be #expected"
&& e.kind == EntityKind::GroovyMethod));
assert!(
entities
.iter()
.any(|e| e.name == "DEFAULT_ROLE" && e.kind == EntityKind::GroovyProperty)
);
assert!(
entities
.iter()
.any(|e| e.name == "maxLoginAttempts" && e.kind == EntityKind::GroovyProperty)
);
assert!(
entities.len() >= 20,
"Expected at least 20 entities, got {}",
entities.len()
);
let global_config = entities
.iter()
.find(|e| e.name == "globalConfig" && e.kind == EntityKind::GroovyProperty)
.expect("globalConfig not extracted");
assert_eq!(
global_config.docstring.as_deref(),
Some("1. Top-level script variables and closures")
);
let user_service = entities
.iter()
.find(|e| e.name == "UserService" && e.kind == EntityKind::GroovyClass)
.expect("UserService not extracted");
assert_eq!(
user_service.docstring.as_deref(),
Some("7. Main Class with Annotations, Inheritance, Traits, and inner classes")
);
let initialize = entities
.iter()
.find(|e| {
e.name == "initialize"
&& e.kind == EntityKind::GroovyMethod
&& e.enclosing_class.as_deref() == Some("UserService")
})
.expect("UserService.initialize not extracted");
assert_eq!(
initialize.docstring.as_deref(),
Some("Typed Method overriding base class")
);
let max_login = entities
.iter()
.find(|e| e.name == "maxLoginAttempts" && e.kind == EntityKind::GroovyProperty)
.expect("maxLoginAttempts not extracted");
assert_eq!(max_login.docstring, None);
}
#[test]
fn test_groovy_fqn_with_package() {
let source = "package com.acme.app\nclass MyService { String greet(String name) { name } }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let class_entity = entities
.iter()
.find(|e| e.name == "MyService")
.expect("MyService class not extracted");
assert_eq!(class_entity.fqn, "com.acme.app.MyService");
let method_entity = entities
.iter()
.find(|e| e.name == "greet")
.expect("greet method not extracted");
assert_eq!(method_entity.fqn, "com.acme.app.MyService.greet");
assert_eq!(method_entity.enclosing_class.as_deref(), Some("MyService"));
}
#[test]
fn test_groovy_method_parent_class() {
let source = "class Calculator {\n int add(int a, int b) { a + b }\n def multiply(int x, int y) { x * y }\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let add_method = entities
.iter()
.find(|e| e.name == "add")
.expect("add method not extracted");
assert_eq!(add_method.enclosing_class.as_deref(), Some("Calculator"));
assert_eq!(add_method.fqn, "Calculator.add");
let multiply_method = entities
.iter()
.find(|e| e.name == "multiply")
.expect("multiply method not extracted");
assert_eq!(
multiply_method.enclosing_class.as_deref(),
Some("Calculator")
);
assert_eq!(multiply_method.fqn, "Calculator.multiply");
}
#[test]
fn test_groovy_interface_method_has_parent() {
let source = "interface Repository {\n String findById(String id)\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let method = entities
.iter()
.find(|e| e.name == "findById")
.expect("findById not extracted");
assert_eq!(method.enclosing_class.as_deref(), Some("Repository"));
}
#[test]
fn test_groovy_nested_scope_tracking() {
let source = "class Outer {\n class Inner {\n String getValue() { 'val' }\n }\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let outer = entities
.iter()
.find(|e| e.name == "Outer")
.expect("Outer class not extracted");
assert_eq!(outer.kind, EntityKind::GroovyClass);
let inner = entities
.iter()
.find(|e| e.name == "Inner")
.expect("Inner class not extracted");
assert_eq!(inner.kind, EntityKind::GroovyClass);
let method = entities
.iter()
.find(|e| e.name == "getValue")
.expect("getValue method not extracted");
assert_eq!(method.enclosing_class.as_deref(), Some("Inner"));
assert_eq!(method.fqn, "Inner.getValue");
}
#[test]
fn test_groovy_trait_method_has_parent() {
let source = "trait Auditable {\n def logAction(String msg) { println msg }\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let method = entities
.iter()
.find(|e| e.name == "logAction")
.expect("logAction not extracted");
assert_eq!(method.enclosing_class.as_deref(), Some("Auditable"));
assert_eq!(method.fqn, "Auditable.logAction");
}
#[test]
fn test_groovy_resilience_empty_file() {
let entities = extract_entities_groovy("", "test.groovy", "test-repo");
assert!(entities.is_empty());
}
#[test]
fn test_groovy_resilience_malformed() {
let source = "garbage {{{ // not valid groovy\nclass ";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(entities.is_empty() || entities.iter().any(|e| e.name == "class"));
}
#[test]
fn test_innermost_assignment_nested_methods() {
let source = r#"
package com.example
class NestedMethods {
def showGrabbingFinishedMessage(String message) {
show(message, new Listener() {
@Override void hyperlinkUpdate(String event) {
runAnalyzer("visualize")
}
})
}
def show(message, Listener listener) {
}
private void runAnalyzer(String action) {
println action
}
}
"#;
let entities = extract_entities_groovy(source, "NestedMethods.groovy", "test-repo");
let hyperlink = entities
.iter()
.find(|e| e.name == "hyperlinkUpdate")
.expect("hyperlinkUpdate not found");
let hyper_has_run = hyperlink
.reference_intents
.iter()
.any(|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "runAnalyzer"));
assert!(
hyper_has_run,
"hyperlinkUpdate should have CALL to runAnalyzer"
);
let outer = entities
.iter()
.find(|e| e.name == "showGrabbingFinishedMessage")
.expect("showGrabbingFinishedMessage not found");
let outer_has_run = outer
.reference_intents
.iter()
.any(|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "runAnalyzer"));
assert!(
!outer_has_run,
"showGrabbingFinishedMessage should NOT have CALL to runAnalyzer (belongs to hyperlinkUpdate)"
);
}
#[test]
fn test_groovy_resilience_missing_braces() {
let source =
"class Broken {\n def method1() { }\n def method2() { }\n// no closing brace";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(entities.iter().any(|e| e.name == "Broken"));
assert!(entities.iter().any(|e| e.name == "method1"));
assert!(entities.iter().any(|e| e.name == "method2"));
}
#[test]
fn test_groovy_class_extends() {
let source = "class Ext1 extends PluginExtensionPoint { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let ext1 = pick_class(&entities, "Ext1");
assert_extends(&ext1.reference_intents, "PluginExtensionPoint");
assert!(collect_implements(&ext1.reference_intents).is_empty());
}
#[test]
fn test_groovy_class_implements() {
let source = "abstract class PluginExtensionPoint implements ExtensionPoint { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "PluginExtensionPoint");
assert_implements(&cls.reference_intents, "ExtensionPoint");
assert!(collect_extends(&cls.reference_intents).is_empty());
}
#[test]
fn test_groovy_class_extends_and_implements_multiple() {
let source =
"class OrderService extends BaseService implements Auditable, Serializable { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "OrderService");
let extends = collect_extends(&cls.reference_intents);
let implements = collect_implements(&cls.reference_intents);
assert_eq!(extends, vec!["BaseService"]);
assert_eq!(implements.len(), 2);
assert!(implements.contains(&"Auditable"));
assert!(implements.contains(&"Serializable"));
}
#[test]
fn test_groovy_extends_with_generics() {
let source = "class Repo extends AbstractRepo<Order, Long> { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "Repo");
assert_extends(&cls.reference_intents, "AbstractRepo");
}
#[test]
fn test_groovy_generic_bound_is_not_extends() {
let source = "class Box<T extends Comparable> { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "Box");
assert!(collect_extends(&cls.reference_intents).is_empty());
assert!(collect_implements(&cls.reference_intents).is_empty());
}
#[test]
fn test_groovy_interface_extends_multiple() {
let source = "interface EventBus extends Publisher, Subscriber { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let iface = pick_entity(&entities, "EventBus", EntityKind::GroovyInterface);
let extends = collect_extends(&iface.reference_intents);
assert_eq!(extends.len(), 2);
assert!(extends.contains(&"Publisher"));
assert!(extends.contains(&"Subscriber"));
assert!(collect_implements(&iface.reference_intents).is_empty());
}
#[test]
fn test_groovy_trait_implements() {
let source = "trait Auditable implements Serializable { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let trait_entity = pick_entity(&entities, "Auditable", EntityKind::GroovyTrait);
let implements = collect_implements(&trait_entity.reference_intents);
assert_eq!(implements, vec!["Serializable"]);
assert!(collect_extends(&trait_entity.reference_intents).is_empty());
}
#[test]
fn test_groovy_enum_implements() {
let source = "enum Status implements Describable { OK, KO }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let enum_entity = pick_entity(&entities, "Status", EntityKind::GroovyEnum);
let implements = collect_implements(&enum_entity.reference_intents);
assert_eq!(implements, vec!["Describable"]);
}
#[test]
fn test_groovy_extends_qualified_name() {
let source = "class Foo extends nextflow.plugin.BasePlugin { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "Foo");
assert_extends(&cls.reference_intents, "nextflow.plugin.BasePlugin");
}
#[test]
fn test_groovy_extends_multiline_declaration() {
let source = "class OrderService extends BaseService<Order>\n implements Auditable, Serializable {\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "OrderService");
let extends = collect_extends(&cls.reference_intents);
let implements = collect_implements(&cls.reference_intents);
assert_eq!(extends, vec!["BaseService"]);
assert_eq!(implements.len(), 2);
assert!(implements.contains(&"Auditable"));
assert!(implements.contains(&"Serializable"));
for intent in &cls.reference_intents {
match intent {
ReferenceIntent::Extends { line, .. }
| ReferenceIntent::Implements { line, .. } => {
assert_eq!(*line, cls.start_line);
}
_ => {}
}
}
}
#[test]
fn test_groovy_class_without_inheritance_has_no_intents() {
let source = "class Plain { def m() {} }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "Plain");
assert!(collect_extends(&cls.reference_intents).is_empty());
assert!(collect_implements(&cls.reference_intents).is_empty());
}
#[test]
fn test_groovy_extends_intent_attached_to_class_not_methods() {
let source = r#"
class Ext1 extends PluginExtensionPoint {
protected void init(Object session) {
runAnalyzer("foo")
}
}
"#;
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "Ext1");
assert_extends(&cls.reference_intents, "PluginExtensionPoint");
let init = entities
.iter()
.find(|e| e.name == "init" && e.kind == EntityKind::GroovyMethod)
.expect("method 'init' not extracted");
assert!(
!init
.reference_intents
.iter()
.any(|r| matches!(r, ReferenceIntent::Extends { .. })),
"method 'init' should not receive the class's Extends intent"
);
assert!(
init.reference_intents.iter().any(
|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "runAnalyzer")
),
"method 'init' should still have CALL to runAnalyzer"
);
}
#[test]
fn test_groovy_extends_line_number() {
let source = "\n\nclass Foo extends Bar {\n}\n";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "Foo");
assert_eq!(cls.start_line, 3);
let extends = collect_extends(&cls.reference_intents);
assert_eq!(extends, vec!["Bar"]);
let intent_line = cls
.reference_intents
.iter()
.find_map(|r| match r {
ReferenceIntent::Extends { line, .. } => Some(*line),
_ => None,
})
.expect("expected Extends intent on Foo");
assert_eq!(
intent_line, cls.start_line,
"Extends intent line must match class declaration line"
);
}
#[test]
fn test_groovy_extends_ignores_comments() {
let source = r#"
// class Fake extends Nope
class Real extends Base {
}
"#;
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
!entities.iter().any(|e| e.name == "Fake"),
"Fake should not be extracted from a comment line"
);
let cls = pick_class(&entities, "Real");
assert_extends(&cls.reference_intents, "Base");
}
fn doc_of(source: &str, decl_line_idx: usize) -> Option<String> {
let lines: Vec<&str> = source.lines().collect();
extract_preceding_docstring(&lines, decl_line_idx)
}
#[test]
fn test_groovy_docstring_block_comment_adjacent() {
let source = "/**\n * Channel factory initialization. This method is invoked one and only once\n *\n * @param session The current nextflow session\n */\nabstract protected void init(Session session)\n";
let doc = doc_of(source, 5).expect("expected docstring for init");
assert!(doc.contains("Channel factory initialization"));
assert!(doc.contains("@param session The current nextflow session"));
assert!(!doc.contains("/**"), "markers must be stripped: {doc:?}");
assert!(!doc.contains("*/"), "markers must be stripped: {doc:?}");
assert!(
!doc.lines().any(|l| l.trim_start().starts_with('*')),
"leading '*' must be stripped: {doc:?}"
);
}
#[test]
fn test_groovy_docstring_skips_annotations() {
let source = "/** doc */\n@PackageScope\nsynchronized void checkInit(Object session) {\n";
let doc = doc_of(source, 2);
assert_eq!(doc.as_deref(), Some("doc"));
}
#[test]
fn test_groovy_docstring_skips_multiple_annotations() {
let source = "/** doc */\n@PackageScope\n@Override\nvoid m() {\n";
let doc = doc_of(source, 3);
assert_eq!(doc.as_deref(), Some("doc"));
}
#[test]
fn test_groovy_docstring_line_comments_burst() {
let source = "// a\n// b\nclass Foo {\n";
let doc = doc_of(source, 2);
assert_eq!(doc.as_deref(), Some("a\nb"));
}
#[test]
fn test_groovy_docstring_tolerates_single_blank_line() {
let source = "/** doc */\n\nvoid m() {\n";
let doc = doc_of(source, 2);
assert_eq!(doc.as_deref(), Some("doc"));
}
#[test]
fn test_groovy_docstring_two_blank_lines_breaks() {
let source = "/** doc */\n\n\nvoid m() {\n";
let doc = doc_of(source, 3);
assert_eq!(doc, None);
}
#[test]
fn test_groovy_docstring_none_when_absent() {
let source = "void other() {\nvoid m() {\n";
let doc = doc_of(source, 1);
assert_eq!(doc, None);
}
#[test]
fn test_groovy_docstring_stops_at_import() {
let source = "/*\n * Licensed under the Apache License\n */\npackage com.example\n\nimport foo.Bar\n\nclass Foo {\n";
let doc = doc_of(source, 7);
assert_eq!(doc, None);
}
#[test]
fn test_groovy_docstring_empty_comment_is_none() {
let source = "/** */\nvoid m() {\n";
assert_eq!(doc_of(source, 1), None);
let source2 = "//\nvoid m() {\n";
assert_eq!(doc_of(source2, 1), None);
}
#[test]
fn test_groovy_docstring_first_line_of_file() {
let source = "class Foo {\n";
assert_eq!(doc_of(source, 0), None);
}
#[test]
fn test_groovy_docstring_malformed_block_no_panic() {
let source = "*/\nclass Foo {\n";
assert_eq!(doc_of(source, 1), None);
let source2 = "package p\n\n * dangling\n */\nclass Foo {\n";
assert_eq!(doc_of(source2, 4), None);
}
#[test]
fn test_groovy_class_has_docstring() {
let source = "/**\n * A service class.\n */\nclass MyService {\n}\n";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let cls = pick_class(&entities, "MyService");
assert_eq!(cls.docstring.as_deref(), Some("A service class."));
}
#[test]
fn test_groovy_abstract_method_has_docstring() {
let source = r#"package nextflow.plugin.extension
abstract class PluginExtensionPoint implements ExtensionPoint {
private boolean initialised
/**
* Channel factory initialization. This method is invoked one and only once
*
* @param session The current nextflow session
*/
abstract protected void init(Session session)
}
"#;
let entities = extract_entities_groovy(source, "PluginExtensionPoint.groovy", "test-repo");
let init = pick_entity(&entities, "init", EntityKind::GroovyMethod);
let doc = init
.docstring
.as_deref()
.expect("init must carry its GroovyDoc");
assert!(doc.contains("Channel factory initialization"));
assert!(!doc.contains("/**") && !doc.contains("*/"));
}
#[test]
fn test_groovy_def_method_has_docstring() {
let source = "class Foo {\n /** Computes the answer. */\n def compute() { 42 }\n}\n";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let m = pick_entity(&entities, "compute", EntityKind::GroovyMethod);
assert_eq!(m.docstring.as_deref(), Some("Computes the answer."));
}
#[test]
fn test_groovy_property_has_docstring() {
let source = "class Foo {\n // The default role\n String role = \"USER\"\n}\n";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let prop = pick_entity(&entities, "role", EntityKind::GroovyProperty);
assert_eq!(prop.docstring.as_deref(), Some("The default role"));
}
#[test]
fn test_groovy_multiline_method_has_docstring() {
let source = r#"class HttpUtil {
/**
* Restart the HTTP server.
*/
private static SimpleHttpServer restartHttpServer(String id, String webRootPath,
Closure handler = {null},
Closure errorListener = {}) {
new SimpleHttpServer()
}
}
"#;
let entities = extract_entities_groovy(source, "HttpUtil.groovy", "test-repo");
let m = pick_entity(&entities, "restartHttpServer", EntityKind::GroovyMethod);
assert_eq!(m.docstring.as_deref(), Some("Restart the HTTP server."));
}
#[test]
fn test_groovy_method_without_doc_has_none() {
let source = "class Foo {\n int add(int a, int b) { a + b }\n}\n";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
let m = pick_entity(&entities, "add", EntityKind::GroovyMethod);
assert_eq!(m.docstring, None);
let cls = pick_class(&entities, "Foo");
assert_eq!(cls.docstring, None);
}
const ISESSION_SRC: &str = r#"
package nf
interface ISession {
/**
* The folder where the main script is contained
*/
Path getBaseDir()
/**
* The pipeline script name (without parent path)
*/
String getScriptName()
}
"#;
const SESSION_SRC: &str = r#"
package nf
class Session implements ISession {
/**
* The folder where the main script is contained
*/
Path baseDir
/**
* The pipeline script name (without parent path)
*/
String scriptName
void setBaseDir( Path baseDir ) {
this.baseDir = baseDir
}
}
"#;
#[test]
fn bug_javadoc_body_line_yields_no_entity() {
let entities = extract_entities_groovy(ISESSION_SRC, "ISession.groovy", "test-repo");
assert!(
!entities.iter().any(|e| e.name == "name"),
"Phantom entity 'name' from Javadoc body line must NOT exist"
);
assert!(
entities
.iter()
.any(|e| e.name == "getBaseDir" && e.kind == EntityKind::GroovyMethod),
"getBaseDir must still be extracted"
);
assert!(
entities
.iter()
.any(|e| e.name == "getScriptName" && e.kind == EntityKind::GroovyMethod),
"getScriptName must still be extracted"
);
}
#[test]
fn bug_bare_property_is_indexed() {
let entities = extract_entities_groovy(SESSION_SRC, "Session.groovy", "test-repo");
let base_dir = entities
.iter()
.find(|e| e.name == "baseDir" && e.kind == EntityKind::GroovyProperty);
assert!(
base_dir.is_some(),
"Bare property 'baseDir' must be indexed"
);
assert_eq!(
base_dir.unwrap().fqn,
"nf.Session.baseDir",
"FQN should include enclosing class"
);
}
#[test]
fn bug_property_getter_is_synthesised() {
let entities = extract_entities_groovy(SESSION_SRC, "Session.groovy", "test-repo");
let getter = entities.iter().find(|e| {
e.name == "getBaseDir"
&& e.kind == EntityKind::GroovyMethod
&& e.enclosing_class.as_deref() == Some("Session")
});
assert!(
getter.is_some(),
"Synthetic getter 'Session.getBaseDir' must exist"
);
assert!(
getter
.unwrap()
.signature
.as_deref()
.is_some_and(|s| s.contains("synthetic")),
"Synthetic getter must carry a synthetic marker in its signature"
);
}
#[test]
fn bug_groovy_scm_query_compiles() {
let q = tree_sitter::Query::new(
&tree_sitter_groovy::LANGUAGE.into(),
include_str!("../../../../queries/groovy.scm"),
);
assert!(q.is_ok(), "groovy.scm failed to compile: {:?}", q.err());
}
#[test]
fn groovy_scm_captures_expected_patterns() {
let q = tree_sitter::Query::new(
&tree_sitter_groovy::LANGUAGE.into(),
include_str!("../../../../queries/groovy.scm"),
)
.expect("groovy.scm must compile");
assert!(
q.pattern_count() >= 12,
"expected at least 12 patterns, got {}",
q.pattern_count()
);
let required: &[&str] = &[
"groovy.method.name",
"groovy.field.name",
"groovy.class.name",
"groovy.interface.name",
"groovy.enum.name",
"groovy.signature",
];
let capture_names: Vec<String> = q.capture_names().iter().map(|c| c.to_string()).collect();
for name in required {
assert!(
capture_names.iter().any(|c| c == name),
"capture '{name}' missing from groovy.scm"
);
}
}
#[test]
fn javadoc_body_with_parens_is_not_a_method() {
let entities = extract_entities_groovy(ISESSION_SRC, "ISession.groovy", "test-repo");
for e in &entities {
if e.kind == EntityKind::GroovyMethod {
assert!(
!e.signature
.as_deref()
.is_some_and(|s| s.contains("parent path")),
"Javadoc body line '{}' must not be a method entity: {:?}",
e.name,
e.signature
);
}
}
assert!(
entities
.iter()
.any(|e| e.name == "getBaseDir" && e.kind == EntityKind::GroovyMethod)
);
assert!(
entities
.iter()
.any(|e| e.name == "getScriptName" && e.kind == EntityKind::GroovyMethod)
);
}
#[test]
fn javadoc_body_does_not_shadow_next_declaration() {
let entities = extract_entities_groovy(SESSION_SRC, "Session.groovy", "test-repo");
let setter = entities.iter().find(|e| {
e.name == "setBaseDir"
&& e.kind == EntityKind::GroovyMethod
&& e.enclosing_class.as_deref() == Some("Session")
});
assert!(
setter.is_some(),
"setBaseDir must be present with enclosing_class=Session"
);
}
#[test]
fn braces_inside_block_comment_do_not_corrupt_scope() {
let source = r#"
class MyService {
/**
* Example: if (x) { doSomething() }
*/
String getName() { "svc" }
}
"#;
let entities = extract_entities_groovy(source, "MyService.groovy", "test-repo");
let method = entities
.iter()
.find(|e| e.name == "getName" && e.kind == EntityKind::GroovyMethod)
.expect("getName not found");
assert_eq!(
method.enclosing_class.as_deref(),
Some("MyService"),
"method's enclosing_class must be the class, not None"
);
}
#[test]
fn single_line_block_comment_does_not_leak() {
let source = "class Foo { /* note */ void run() {} }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "Foo" && e.kind == EntityKind::GroovyClass),
"Foo should be extracted"
);
}
#[test]
fn trailing_line_comment_is_ignored() {
let source = "class Foo {\n Path baseDir // the base dir\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "baseDir" && e.kind == EntityKind::GroovyProperty),
"baseDir with trailing line comment should be extracted"
);
}
#[test]
fn unterminated_block_comment_swallows_rest_of_file() {
let source = "class Foo {\n/**\nPath baseDir\nString name\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
!entities.iter().any(|e| e.name == "baseDir"),
"entities after unterminated /** should not exist"
);
assert!(
!entities.iter().any(|e| e.name == "name"),
"entities after unterminated /** should not exist"
);
}
#[test]
fn strip_comments_line_unit() {
let cases: &[(&str, bool, &str, bool)] = &[
("code", false, "code", false),
("code // comment", false, "code", false),
("/* block */ code", false, "code", false),
("/* start", false, "", true),
("* mid", true, "", true),
("*/ after", true, "after", false),
("/** doc */", false, "", false),
(" ", false, "", false),
(
"x = \"// not a comment\"",
false,
"x = \"// not a comment\"",
false,
),
];
for (i, (input, in_before, expected, in_after)) in cases.iter().enumerate() {
let mut in_block = *in_before;
let result = strip_comments_line(input, &mut in_block);
assert_eq!(
result.trim(),
*expected,
"case {i}: strip_comments_line({input:?}, {in_before})"
);
assert_eq!(
in_block, *in_after,
"case {i}: in_block after strip_comments_line"
);
}
}
#[test]
fn bare_typed_property_is_extracted() {
let source = "class Session {\n Path baseDir\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
let prop = pick_entity(&entities, "baseDir", EntityKind::GroovyProperty);
assert_eq!(prop.enclosing_class.as_deref(), Some("Session"));
assert_eq!(prop.fqn, "Session.baseDir");
}
#[test]
fn generic_typed_property_is_extracted() {
let source = "class Session {\n Map<String,Object> config\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "config" && e.kind == EntityKind::GroovyProperty),
"generic property 'config' not found"
);
}
#[test]
fn def_property_is_extracted() {
let source = "class Session {\n def anything\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "anything" && e.kind == EntityKind::GroovyProperty),
"def property 'anything' not found"
);
}
#[test]
fn modifier_prefixed_property_is_extracted() {
let source = "class Session {\n private static final Path ROOT\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "ROOT" && e.kind == EntityKind::GroovyProperty),
"modifier-prefixed property 'ROOT' not found"
);
}
#[test]
fn java_style_semicolon_field_is_extracted() {
let source = "class Session {\n private int count;\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "count" && e.kind == EntityKind::GroovyProperty),
"semicolon field 'count' not found"
);
}
#[test]
fn initialized_property_still_extracted() {
let source = "class Foo { String name = 'test' }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "name" && e.kind == EntityKind::GroovyProperty),
"initialized property 'name' not found"
);
}
#[test]
fn local_variable_inside_method_is_not_a_property() {
let source = "class Foo { void m() { Path tmp\n } }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
!entities.iter().any(|e| e.name == "tmp"),
"local variable 'tmp' inside method must NOT be a property"
);
}
#[test]
fn return_statement_is_not_a_property() {
let source = "class Foo { void m() { return baseDir } }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
!entities.iter().any(|e| e.name == "return"),
"'return' must not be a property"
);
}
#[test]
fn import_and_package_lines_are_not_properties() {
let source = "package com.foo\nimport java.nio.Path\nclass Foo { }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
!entities
.iter()
.any(|e| e.name == "Path" && e.kind == EntityKind::GroovyProperty),
"'Path' from import must not be a property"
);
}
#[test]
fn type_declaration_line_is_not_a_property() {
let source = "class Session implements ISession { String name }";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
!entities
.iter()
.any(|e| e.name == "Session" && e.kind == EntityKind::GroovyProperty),
"class name must not be misclassified as property"
);
}
#[test]
fn script_level_bare_identifier_is_not_a_property() {
let source = "println";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
!entities
.iter()
.any(|e| e.name == "println" && e.kind == EntityKind::GroovyProperty),
"single token 'println' must not be a property"
);
}
#[test]
fn property_generates_getter_and_setter() {
let source = "class Session {\n Path baseDir\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
let getter = entities.iter().find(|e| {
e.name == "getBaseDir"
&& e.kind == EntityKind::GroovyMethod
&& e.enclosing_class.as_deref() == Some("Session")
});
assert!(getter.is_some(), "getter 'getBaseDir' must be synthesised");
let setter = entities.iter().find(|e| {
e.name == "setBaseDir"
&& e.kind == EntityKind::GroovyMethod
&& e.enclosing_class.as_deref() == Some("Session")
});
assert!(setter.is_some(), "setter 'setBaseDir' must be synthesised");
}
#[test]
fn boolean_property_generates_is_and_get() {
let source = "class Session {\n boolean cacheable\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
assert!(
entities.iter().any(|e| {
e.name == "isCacheable"
&& e.kind == EntityKind::GroovyMethod
&& e.enclosing_class.as_deref() == Some("Session")
}),
"boolean is-accessor not synthesised"
);
assert!(
entities.iter().any(|e| {
e.name == "getCacheable"
&& e.kind == EntityKind::GroovyMethod
&& e.enclosing_class.as_deref() == Some("Session")
}),
"boolean getter not synthesised"
);
}
#[test]
fn boxed_boolean_property_generates_is_and_get() {
let source = "class Session {\n Boolean resumeMode\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
assert!(
entities.iter().any(|e| e.name == "isResumeMode"),
"Boolean is-accessor not synthesised"
);
assert!(
entities.iter().any(|e| e.name == "getResumeMode"),
"Boolean getter not synthesised"
);
}
#[test]
fn final_property_generates_getter_only() {
let source = "class Session {\n final Path root\n}";
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "getRoot" && e.kind == EntityKind::GroovyMethod),
"final property must have getter"
);
assert!(
!entities
.iter()
.any(|e| e.name == "setRoot" && e.kind == EntityKind::GroovyMethod),
"final property must NOT have setter"
);
}
#[test]
fn explicit_setter_suppresses_synthetic_setter() {
let entities = extract_entities_groovy(SESSION_SRC, "Session.groovy", "test-repo");
let setters: Vec<_> = entities
.iter()
.filter(|e| e.name == "setBaseDir" && e.kind == EntityKind::GroovyMethod)
.collect();
assert_eq!(
setters.len(),
1,
"must be exactly one setBaseDir, got {}",
setters.len()
);
let s = setters[0];
assert!(
!s.signature
.as_deref()
.is_some_and(|sig| sig.contains("synthetic")),
"the setBaseDir must be the real one, not synthetic"
);
}
#[test]
fn explicit_getter_suppresses_synthetic_getter() {
let source = r#"
class Session {
Path baseDir
Path getBaseDir() { baseDir }
}
"#;
let entities = extract_entities_groovy(source, "Session.groovy", "test-repo");
let getters: Vec<_> = entities
.iter()
.filter(|e| e.name == "getBaseDir" && e.kind == EntityKind::GroovyMethod)
.collect();
assert_eq!(getters.len(), 1, "exactly one getBaseDir expected");
}
#[test]
fn interface_constant_generates_no_accessor() {
let source = "interface I { String NAME }";
let entities = extract_entities_groovy(source, "I.groovy", "test-repo");
assert!(
!entities.iter().any(|e| e.name == "getNAME"),
"interface constants must not generate accessors"
);
}
#[test]
fn script_level_variable_generates_no_accessor() {
let source = "def globalConfig = [:]";
let entities = extract_entities_groovy(source, "script.groovy", "test-repo");
assert!(
!entities.iter().any(|e| e.name == "getGlobalConfig"),
"script-level variable must not generate accessor"
);
}
#[test]
fn synthetic_accessor_metadata() {
let entities = extract_entities_groovy(SESSION_SRC, "Session.groovy", "test-repo");
let getter = entities
.iter()
.find(|e| {
e.name == "getBaseDir"
&& e.kind == EntityKind::GroovyMethod
&& e.signature
.as_deref()
.is_some_and(|s| s.contains("synthetic"))
})
.expect("synthetic getter not found");
let prop = entities
.iter()
.find(|e| e.name == "baseDir" && e.kind == EntityKind::GroovyProperty)
.expect("baseDir property not found");
assert_eq!(
getter.enclosing_class.as_deref(),
Some("Session"),
"synthetic getter must have enclosing class"
);
assert_eq!(
getter.start_line, prop.start_line,
"synthetic getter must share property's start_line"
);
}
#[test]
fn synthetic_accessor_uuid_is_distinct() {
let entities = extract_entities_groovy(SESSION_SRC, "Session.groovy", "test-repo");
let getter = entities
.iter()
.find(|e| {
e.name == "getBaseDir"
&& e.kind == EntityKind::GroovyMethod
&& e.signature
.as_deref()
.is_some_and(|s| s.contains("synthetic"))
})
.expect("synthetic getter not found");
let prop = entities
.iter()
.find(|e| e.name == "baseDir" && e.kind == EntityKind::GroovyProperty)
.expect("baseDir property not found");
assert_ne!(
getter.uuid, prop.uuid,
"synthetic getter UUID must be distinct from property UUID"
);
}
#[test]
fn synthetic_accessors_have_no_reference_intents() {
let entities = extract_entities_groovy(SESSION_SRC, "Session.groovy", "test-repo");
for e in entities.iter().filter(|e| {
e.kind == EntityKind::GroovyMethod
&& e.signature
.as_deref()
.is_some_and(|s| s.contains("synthetic"))
}) {
assert!(
e.reference_intents.is_empty(),
"synthetic accessor '{}' must have no reference intents",
e.name
);
}
}
#[test]
fn url_string_with_double_slash_is_tolerated() {
let source = "class Foo {\n String url = \"https://example.com/path\"\n}";
let entities = extract_entities_groovy(source, "test.groovy", "test-repo");
assert!(
entities
.iter()
.any(|e| e.name == "url" && e.kind == EntityKind::GroovyProperty),
"url property should still be extracted"
);
}
}
#[test]
fn test_all_typed_methods_no_duplication() {
let source = r#"
class HttpUtil {
private static void restartHttpServer() {
println "hello"
}
void loadIntoHttpServer(String html) {
restartHttpServer()
}
}
"#;
let entities = extract_entities_groovy(source, "HttpUtil.groovy", "test-repo");
let r_count = entities
.iter()
.filter(|e| e.name == "restartHttpServer")
.count();
let l_count = entities
.iter()
.filter(|e| e.name == "loadIntoHttpServer")
.count();
assert_eq!(r_count, 1, "restartHttpServer duplicated");
assert_eq!(l_count, 1, "loadIntoHttpServer duplicated");
}
#[test]
fn test_def_methods_call_typed_private_method() {
let source = r#"
class HttpUtil {
private static void restartHttpServer() {
println "hello"
}
def loadIntoHttpServer(String html) {
restartHttpServer()
}
}
"#;
let entities = extract_entities_groovy(source, "HttpUtil.groovy", "test-repo");
let load = entities.iter().find(|e| e.name == "loadIntoHttpServer");
assert!(load.is_some(), "loadIntoHttpServer not found");
let load = load.unwrap();
let calls_to_restart = load
.reference_intents
.iter()
.filter(
|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "restartHttpServer"),
)
.count();
assert!(
calls_to_restart > 0,
"Expected def method to have CALL to restartHttpServer"
);
}
#[test]
fn test_no_paren_call_detection() {
let source = r#"
class Worker {
void process() {
runAnalyzer "abc", 123
doSomething result
println "hello"
}
}
"#;
let entities = extract_entities_groovy(source, "Worker.groovy", "test-repo");
let process = entities
.iter()
.find(|e| e.name == "process")
.expect("process not found");
let refs: Vec<String> = process
.reference_intents
.iter()
.map(|r| match r {
ReferenceIntent::Call {
method,
receiver,
line,
arg_count: _,
} => format!(
"Call({}{}, line {})",
receiver
.as_ref()
.map(|r| format!("{}.", r))
.unwrap_or_default(),
method,
line
),
_ => format!("{:?}", r),
})
.collect();
eprintln!("process reference_intents: {:?}", refs);
let has_run = process
.reference_intents
.iter()
.any(|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "runAnalyzer"));
assert!(has_run);
let has_do = process
.reference_intents
.iter()
.any(|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "doSomething"));
assert!(has_do);
let has_println = process
.reference_intents
.iter()
.any(|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "println"));
assert!(!has_println);
}
#[test]
fn test_private_method_with_closure_args_is_callable() {
let source = r#"
package test
import com.example.SimpleHttpServer
class HttpUtil {
static String loadIntoHttpServer(String html) {
def server = restartHttpServer("web", "/tmp", {null}, {log?.errorOnHttpRequest(it.toString())})
"http://localhost"
}
private static SimpleHttpServer restartHttpServer(String id, String webRootPath,
Closure handler = {null},
Closure errorListener = {}) {
def server = new SimpleHttpServer()
server
}
}
"#;
let entities = extract_entities_groovy(source, "HttpUtil.groovy", "test-repo");
let load = entities.iter().find(|e| e.name == "loadIntoHttpServer");
assert!(load.is_some(), "loadIntoHttpServer not found");
let load = load.unwrap();
assert_eq!(
load.enclosing_class.as_deref(),
Some("HttpUtil"),
"loadIntoHttpServer should have enclosing_class HttpUtil"
);
assert!(
!load.fqn.is_empty(),
"loadIntoHttpServer should have non-empty FQN, got: '{}'",
load.fqn
);
let calls_restart = load
.reference_intents
.iter()
.filter(
|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "restartHttpServer"),
)
.count();
assert!(
calls_restart > 0,
"Expected loadIntoHttpServer to have CALL to restartHttpServer, but found {} call(s). refs: {:?}",
calls_restart,
load.reference_intents
.iter()
.filter_map(|r| match r {
ReferenceIntent::Call { method, line, .. } => Some(format!("{}@L{}", method, line)),
_ => None,
})
.collect::<Vec<_>>()
);
let restart = entities.iter().find(|e| e.name == "restartHttpServer");
assert!(restart.is_some(), "restartHttpServer not found in entities");
let restart = restart.unwrap();
assert_eq!(
restart.enclosing_class.as_deref(),
Some("HttpUtil"),
"restartHttpServer should have enclosing_class HttpUtil"
);
assert!(
!restart.fqn.is_empty(),
"restartHttpServer should have non-empty FQN, got: '{}'",
restart.fqn
);
assert!(
restart.enclosing_class.is_some(),
"restartHttpServer should have enclosing_class set"
);
}
#[test]
fn test_new_constructor_not_method_declaration() {
let source = r#"
class HttpUtil {
static String loadIntoHttpServer(String html) {
def tempDir = FileUtil.createTempDirectory("proj", "")
new File("path").write(html)
def server = restartHttpServer("web", "/tmp", {null}, {log?.errorOnHttpRequest(it.toString())})
"http://localhost"
}
private static SimpleHttpServer restartHttpServer(String id, String webRootPath,
Closure handler = {null},
Closure errorListener = {}) {
def server = new SimpleHttpServer()
server
}
}
"#;
let entities = extract_entities_groovy(source, "HttpUtil.groovy", "test-repo");
assert!(
!entities
.iter()
.any(|e| e.kind == EntityKind::GroovyMethod && e.name == "File"),
"new File(...) was incorrectly extracted as a method declaration"
);
let ssh_methods: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::GroovyMethod && e.name == "SimpleHttpServer")
.collect();
assert!(
ssh_methods.len() <= 1,
"new SimpleHttpServer() constructor should not create method entities, found {}: {:?}",
ssh_methods.len(),
ssh_methods.iter().map(|e| e.start_line).collect::<Vec<_>>()
);
let load = entities
.iter()
.find(|e| e.name == "loadIntoHttpServer")
.expect("loadIntoHttpServer not found");
let calls_restart = load
.reference_intents
.iter()
.filter(
|r| matches!(r, ReferenceIntent::Call { method, .. } if method == "restartHttpServer"),
)
.count();
assert!(
calls_restart > 0,
"loadIntoHttpServer should call restartHttpServer"
);
}