Skip to main content

normalize_languages/
groovy.rs

1//! Groovy language support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8/// Groovy language support.
9pub struct Groovy;
10
11impl Language for Groovy {
12    fn name(&self) -> &'static str {
13        "Groovy"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["groovy", "gradle", "gvy", "gy", "gsh"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "groovy"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["class_definition"] // Groovy grammar only has class_definition
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["function_definition", "closure"]
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &["class_definition"]
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["groovy_import"]
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["class_definition", "function_definition"]
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::AccessModifier // public, private, protected
48    }
49
50    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
51        let name = match self.node_name(node, content) {
52            Some(n) => n.to_string(),
53            None => return Vec::new(),
54        };
55
56        let kind = match node.kind() {
57            "class_definition" => SymbolKind::Class,
58            "function_definition" => SymbolKind::Function,
59            _ => return Vec::new(),
60        };
61
62        vec![Export {
63            name,
64            kind,
65            line: node.start_position().row + 1,
66        }]
67    }
68
69    fn scope_creating_kinds(&self) -> &'static [&'static str] {
70        &["class_definition", "function_definition", "closure"]
71    }
72
73    fn control_flow_kinds(&self) -> &'static [&'static str] {
74        &[
75            "if_statement",
76            "for_loop",
77            "for_in_loop",
78            "while_loop",
79            "switch_statement",
80            "try_statement",
81        ]
82    }
83
84    fn complexity_nodes(&self) -> &'static [&'static str] {
85        &[
86            "if_statement",
87            "for_loop",
88            "for_in_loop",
89            "while_loop",
90            "switch_statement",
91            "case",
92            "ternary_op",
93        ]
94    }
95
96    fn nesting_nodes(&self) -> &'static [&'static str] {
97        &[
98            "class_definition",
99            "function_definition",
100            "if_statement",
101            "for_loop",
102            "closure",
103        ]
104    }
105
106    fn signature_suffix(&self) -> &'static str {
107        " {}"
108    }
109
110    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
111        let name = self.node_name(node, content)?;
112        let text = &content[node.byte_range()];
113        let first_line = text.lines().next().unwrap_or(text);
114
115        Some(Symbol {
116            name: name.to_string(),
117            kind: SymbolKind::Function,
118            signature: first_line.trim().to_string(),
119            docstring: self.extract_docstring(node, content),
120            attributes: Vec::new(),
121            start_line: node.start_position().row + 1,
122            end_line: node.end_position().row + 1,
123            visibility: self.get_visibility(node, content),
124            children: Vec::new(),
125            is_interface_impl: false,
126            implements: Vec::new(),
127        })
128    }
129
130    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
131        let name = self.node_name(node, content)?;
132
133        let kind = match node.kind() {
134            "class_definition" => SymbolKind::Class,
135            _ => return None,
136        };
137
138        let text = &content[node.byte_range()];
139        let first_line = text.lines().next().unwrap_or(text);
140
141        Some(Symbol {
142            name: name.to_string(),
143            kind,
144            signature: first_line.trim().to_string(),
145            docstring: self.extract_docstring(node, content),
146            attributes: Vec::new(),
147            start_line: node.start_position().row + 1,
148            end_line: node.end_position().row + 1,
149            visibility: self.get_visibility(node, content),
150            children: Vec::new(),
151            is_interface_impl: false,
152            implements: Vec::new(),
153        })
154    }
155
156    fn extract_type(&self, node: &Node, content: &str) -> Option<Symbol> {
157        self.extract_container(node, content)
158    }
159
160    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
161        // Groovy uses /** */ for Javadoc-style comments
162        let mut prev = node.prev_sibling();
163        while let Some(sibling) = prev {
164            let text = &content[sibling.byte_range()];
165            if sibling.kind() == "comment" {
166                if text.starts_with("/**") {
167                    let inner = text.trim_start_matches("/**").trim_end_matches("*/").trim();
168                    if !inner.is_empty() {
169                        // Get first non-empty line, strip leading *
170                        for line in inner.lines() {
171                            let line = line.trim().trim_start_matches('*').trim();
172                            if !line.is_empty() && !line.starts_with('@') {
173                                return Some(line.to_string());
174                            }
175                        }
176                    }
177                }
178            }
179            prev = sibling.prev_sibling();
180        }
181        None
182    }
183
184    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
185        Vec::new()
186    }
187
188    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
189        if node.kind() != "groovy_import" {
190            return Vec::new();
191        }
192
193        let text = &content[node.byte_range()];
194        let line = node.start_position().row + 1;
195
196        // import foo.bar.Baz or import foo.bar.*
197        if let Some(rest) = text.strip_prefix("import ") {
198            let rest = rest.strip_prefix("static ").unwrap_or(rest);
199            let module = rest.trim().trim_end_matches(';').to_string();
200            let is_wildcard = module.ends_with(".*");
201
202            return vec![Import {
203                module: module.trim_end_matches(".*").to_string(),
204                names: Vec::new(),
205                alias: None,
206                is_wildcard,
207                is_relative: false,
208                line,
209            }];
210        }
211
212        Vec::new()
213    }
214
215    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
216        // Groovy: import pkg.Class or import pkg.*
217        let names_to_use: Vec<&str> = names
218            .map(|n| n.to_vec())
219            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
220        if import.is_wildcard {
221            format!("import {}.*", import.module)
222        } else if names_to_use.is_empty() {
223            format!("import {}", import.module)
224        } else if names_to_use.len() == 1 {
225            format!("import {}.{}", import.module, names_to_use[0])
226        } else {
227            // Groovy doesn't have multi-import syntax, so format as module
228            format!("import {}", import.module)
229        }
230    }
231
232    fn is_public(&self, node: &Node, content: &str) -> bool {
233        let text = &content[node.byte_range()];
234        !text.starts_with("private") && !text.starts_with("protected")
235    }
236
237    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
238        let text = &content[node.byte_range()];
239        if text.starts_with("private") {
240            Visibility::Private
241        } else if text.starts_with("protected") {
242            Visibility::Protected
243        } else {
244            Visibility::Public
245        }
246    }
247
248    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
249        let has_test_attr = symbol.attributes.iter().any(|a| a.contains("@Test"));
250        if has_test_attr {
251            return true;
252        }
253        match symbol.kind {
254            crate::SymbolKind::Class => {
255                symbol.name.starts_with("Test") || symbol.name.ends_with("Test")
256            }
257            _ => false,
258        }
259    }
260
261    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
262        None
263    }
264
265    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
266        node.child_by_field_name("body")
267    }
268
269    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
270        false
271    }
272
273    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
274        node.child_by_field_name("name")
275            .map(|n| &content[n.byte_range()])
276    }
277
278    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
279        let ext = path.extension()?.to_str()?;
280        if !["groovy", "gradle", "gvy", "gy", "gsh"].contains(&ext) {
281            return None;
282        }
283        let stem = path.file_stem()?.to_str()?;
284        Some(stem.to_string())
285    }
286
287    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
288        let path = module.replace('.', "/");
289        vec![
290            format!("{}.groovy", path),
291            format!("src/main/groovy/{}.groovy", path),
292        ]
293    }
294
295    fn lang_key(&self) -> &'static str {
296        "groovy"
297    }
298
299    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
300        import_name.starts_with("groovy.")
301            || import_name.starts_with("java.")
302            || import_name.starts_with("javax.")
303    }
304
305    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
306        None
307    }
308
309    fn resolve_local_import(&self, import: &str, _: &Path, project_root: &Path) -> Option<PathBuf> {
310        let path = import.replace('.', "/");
311        let candidates = [
312            project_root
313                .join("src/main/groovy")
314                .join(format!("{}.groovy", path)),
315            project_root.join(format!("{}.groovy", path)),
316        ];
317        for c in &candidates {
318            if c.is_file() {
319                return Some(c.clone());
320            }
321        }
322        None
323    }
324
325    fn resolve_external_import(&self, _: &str, _: &Path) -> Option<ResolvedPackage> {
326        None
327    }
328
329    fn get_version(&self, project_root: &Path) -> Option<String> {
330        if project_root.join("build.gradle").is_file() {
331            return Some("Gradle".to_string());
332        }
333        if project_root.join("build.gradle.kts").is_file() {
334            return Some("Gradle (Kotlin DSL)".to_string());
335        }
336        None
337    }
338
339    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
340        if let Some(home) = std::env::var_os("HOME") {
341            let gradle = PathBuf::from(home).join(".gradle/caches/modules-2/files-2.1");
342            if gradle.is_dir() {
343                return Some(gradle);
344            }
345        }
346        None
347    }
348
349    fn indexable_extensions(&self) -> &'static [&'static str] {
350        &["groovy", "gvy"]
351    }
352    fn package_sources(&self, _: &Path) -> Vec<crate::PackageSource> {
353        Vec::new()
354    }
355
356    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
357        use crate::traits::{has_extension, skip_dotfiles};
358        if skip_dotfiles(name) {
359            return true;
360        }
361        if is_dir && (name == "build" || name == ".gradle") {
362            return true;
363        }
364        !is_dir && !has_extension(name, self.indexable_extensions())
365    }
366
367    fn discover_packages(&self, _: &crate::PackageSource) -> Vec<(String, PathBuf)> {
368        Vec::new()
369    }
370
371    fn package_module_name(&self, entry_name: &str) -> String {
372        entry_name
373            .strip_suffix(".groovy")
374            .or_else(|| entry_name.strip_suffix(".gradle"))
375            .or_else(|| entry_name.strip_suffix(".gvy"))
376            .unwrap_or(entry_name)
377            .to_string()
378    }
379
380    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
381        if path.is_file() {
382            Some(path.to_path_buf())
383        } else {
384            None
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::validate_unused_kinds_audit;
393
394    #[test]
395    fn unused_node_kinds_audit() {
396        #[rustfmt::skip]
397        let documented_unused: &[&str] = &[
398            "access_modifier", "array_type", "builtintype", "declaration",
399            "do_while_loop", "dotted_identifier", "for_parameters",
400            "function_call", "function_declaration", "groovy_doc_throws",
401            "identifier", "juxt_function_call", "modifier",
402            "parenthesized_expression", "qualified_name", "return", "switch_block",
403            "type_with_generics", "wildcard_import",
404        ];
405        validate_unused_kinds_audit(&Groovy, documented_unused)
406            .expect("Groovy unused node kinds audit failed");
407    }
408}