diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d29fa11b3..effa4a926 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -4459,3 +4459,157 @@ func (s Stack[T]) Len() int { return len(s.items) } expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined(); }); }); + +// ============================================================================= +// Groovy +// ============================================================================= + +describe('Groovy Extraction', () => { + describe('Language detection', () => { + it('should detect Groovy files', () => { + expect(detectLanguage('build.gradle')).toBe('groovy'); + expect(detectLanguage('src/Main.groovy')).toBe('groovy'); + expect(detectLanguage('utils.gvy')).toBe('groovy'); + expect(detectLanguage('script.gy')).toBe('groovy'); + expect(detectLanguage('helper.gsh')).toBe('groovy'); + }); + }); + + it('should extract class declarations', () => { + const code = ` +class UserService { + private repository + + UserService(repository) { + this.repository = repository + } + + def getUser(String id) { + return repository.findById(id) + } +} +`; + const result = extractFromSource('UserService.groovy', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('UserService'); + }); + + it('should extract method declarations', () => { + const code = ` +class Calculator { + static int add(int a, int b) { + return a + b + } +} +`; + const result = extractFromSource('Calculator.groovy', code); + + const methodNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'add'); + expect(methodNode).toBeDefined(); + expect(methodNode?.isStatic).toBe(true); + }); + + it('should extract def function declarations', () => { + const code = ` +def greet(String name) { + return "Hello, \${name}" +} +`; + const result = extractFromSource('utils.groovy', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet'); + expect(funcNode).toBeDefined(); + expect(funcNode?.language).toBe('groovy'); + }); + + it('should extract interface declarations', () => { + const code = ` +interface Repository { + def findById(String id) + def save(Object entity) +} +`; + const result = extractFromSource('Repository.groovy', code); + + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode).toBeDefined(); + expect(ifaceNode?.name).toBe('Repository'); + }); + + it('should extract enum declarations', () => { + const code = ` +enum Color { + RED, GREEN, BLUE +} +`; + const result = extractFromSource('Color.groovy', code); + + const enumNode = result.nodes.find((n) => n.kind === 'enum'); + expect(enumNode).toBeDefined(); + expect(enumNode?.name).toBe('Color'); + }); + + it('should extract imports', () => { + const code = ` +import java.util.List +import groovy.json.JsonSlurper +`; + const result = extractFromSource('App.groovy', code); + + const imports = result.nodes.filter((n) => n.kind === 'import'); + expect(imports.length).toBe(2); + expect(imports.map((n) => n.name)).toContain('java.util.List'); + expect(imports.map((n) => n.name)).toContain('groovy.json.JsonSlurper'); + }); + + it('should extract visibility modifiers', () => { + const code = ` +class Account { + private String secret + protected String internal + public String visible +} +`; + const result = extractFromSource('Account.groovy', code); + + const fields = result.nodes.filter((n) => n.kind === 'field'); + const secret = fields.find((n) => n.name === 'secret'); + const internal = fields.find((n) => n.name === 'internal'); + const visible = fields.find((n) => n.name === 'visible'); + expect(secret?.visibility).toBe('private'); + expect(internal?.visibility).toBe('protected'); + expect(visible?.visibility).toBe('public'); + }); + + it('should extract package declarations', () => { + const code = ` +package com.example.service + +class UserService {} +`; + const result = extractFromSource('UserService.groovy', code); + + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns).toBeDefined(); + expect(ns?.name).toBe('com.example.service'); + }); + + it('should extract bare calls (juxt_function_call)', () => { + const code = ` +def greet(String name) { + println "Hello, \${name}" +} +`; + const result = extractFromSource('utils.groovy', code); + + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + expect(calls.some((r) => r.referenceName === 'println')).toBe(true); + }); + + it('should report Groovy as supported', () => { + expect(isLanguageSupported('groovy')).toBe(true); + expect(getSupportedLanguages()).toContain('groovy'); + }); +}); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 576845e20..1535458e9 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record = { lua: 'tree-sitter-lua.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + groovy: 'tree-sitter-groovy.wasm', }; /** @@ -99,6 +100,11 @@ export const EXTENSION_MAP: Record = { '.sc': 'scala', '.lua': 'lua', '.luau': 'luau', + '.groovy': 'groovy', + '.gradle': 'groovy', + '.gvy': 'groovy', + '.gy': 'groovy', + '.gsh': 'groovy', '.m': 'objc', '.mm': 'objc', // XML: file-level tracking; the MyBatis extractor matches `` @@ -185,7 +191,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise { + const params = getChildByField(node, 'parameters'); + const returnType = getChildByField(node, 'type'); + if (!params) return undefined; + const paramsText = getNodeText(params, source); + return returnType ? getNodeText(returnType, source) + ' ' + paramsText : paramsText; + }, + getVisibility: (node) => { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.type === 'modifiers') { + const text = child.text; + if (text.includes('public')) return 'public'; + if (text.includes('private')) return 'private'; + if (text.includes('protected')) return 'protected'; + } + } + return undefined; + }, + isStatic: (node) => { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.type === 'modifiers' && child.text.includes('static')) { + return true; + } + } + return false; + }, + extractImport: (node, source) => { + const importText = source.substring(node.startIndex, node.endIndex).trim(); + const scopedId = node.namedChildren.find((c: SyntaxNode) => c.type === 'scoped_identifier'); + if (scopedId) { + const moduleName = source.substring(scopedId.startIndex, scopedId.endIndex); + return { moduleName, signature: importText }; + } + return null; + }, + packageTypes: ['package_declaration'], + extractPackage: (node, source) => { + const id = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'scoped_identifier' || c.type === 'identifier' + ); + return id ? source.substring(id.startIndex, id.endIndex).trim() : null; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 543598b8e..69f8e39eb 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -26,6 +26,7 @@ import { scalaExtractor } from './scala'; import { luaExtractor } from './lua'; import { luauExtractor } from './luau'; import { objcExtractor } from './objc'; +import { groovyExtractor } from './groovy'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial> = { lua: luaExtractor, luau: luauExtractor, objc: objcExtractor, + groovy: groovyExtractor, }; diff --git a/src/extraction/wasm/tree-sitter-groovy.wasm b/src/extraction/wasm/tree-sitter-groovy.wasm new file mode 100644 index 000000000..46db63d5c Binary files /dev/null and b/src/extraction/wasm/tree-sitter-groovy.wasm differ diff --git a/src/types.ts b/src/types.ts index e710e31a1..b842af319 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,6 +88,7 @@ export const LANGUAGES = [ 'lua', 'luau', 'objc', + 'groovy', 'yaml', 'twig', 'xml',