diff --git a/graphify/detect.py b/graphify/detect.py index 87086021e..885db9bc1 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -18,7 +18,7 @@ class FileType(str, Enum): _MANIFEST_PATH = "graphify-out/manifest.json" -CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08'} +CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08'} DOC_EXTENSIONS = {'.md', '.mdx', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 42fd78f74..3b12f0963 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -726,6 +726,18 @@ _JAVA_CONFIG = LanguageConfig( import_handler=_import_java, ) +_GROOVY_CONFIG = LanguageConfig( + ts_module="tree_sitter_groovy", + class_types=frozenset({"class_declaration", "interface_declaration"}), + function_types=frozenset({"method_declaration", "constructor_declaration"}), + import_types=frozenset({"import_declaration"}), + call_types=frozenset({"method_invocation"}), + call_function_field="name", + call_accessor_node_types=frozenset(), + function_boundary_types=frozenset({"method_declaration", "constructor_declaration"}), + import_handler=_import_java, +) + _C_CONFIG = LanguageConfig( ts_module="tree_sitter_c", class_types=frozenset(), @@ -1815,6 +1827,122 @@ def extract_java(path: Path) -> dict: return _extract_generic(path, _JAVA_CONFIG) +def _is_spock_file(path: Path, ts_result: dict) -> bool: + """Return True when the file contains Spock-style ``def "feature"()`` methods + that tree-sitter-groovy cannot parse, detected by checking the raw source.""" + import re as _re + _SPOCK_FEATURE_RE = _re.compile(r"""^\s*def\s+[\"']""", _re.MULTILINE) + try: + return bool(_SPOCK_FEATURE_RE.search(path.read_text(errors="replace"))) + except OSError: + return False + + +def _extract_spock_fallback(path: Path, ts_result: dict) -> dict: + """Regex-based fallback for Spock spec files where tree-sitter-groovy cannot parse + ``def "feature name"()`` methods. Merges import edges from the tree-sitter pass + (which survive reliably) with class and feature-method nodes extracted via regex. + """ + import re as _re + source = path.read_text(errors="replace") + str_path = str(path) + stem = _file_stem(path) + + # Only keep the file node from the tree-sitter pass (guaranteed present and + # correctly IDed) plus all import edges. All other ts nodes are discarded to + # avoid orphaned method/constructor nodes whose parent edges were dropped. + file_node = next((n for n in ts_result.get("nodes", []) if n.get("label") == path.name), None) + nodes: list[dict] = [file_node] if file_node else [] + edges: list[dict] = [e for e in ts_result.get("edges", []) if e.get("context") == "import"] + seen_ids: set[str] = {n["id"] for n in nodes} + + def _add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, + "label": label, + "file_type": "code", + "source_file": str_path, + "source_location": f"L{line}", + }) + + def _add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED") -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + lines_text = source.splitlines() + + # Extract class declarations + class_re = _re.compile(r"^\s*(?:[\w@]+\s+)*class\s+(\w+)") + # Extract Spock feature methods: def "..." () or def '...' () + # Two separate capture groups per quote style so apostrophes inside + # double-quoted names (e.g. "shouldn't") are captured correctly. + feature_re = _re.compile(r"""^\s*def\s+(?:\"([^\"]+)\"|'([^']+)')\s*\(""") + # Extract plain def methods (non-string names) as well + plain_method_re = _re.compile(r"""^\s*def\s+(\w+)\s*\(""") + + current_class_nid: str | None = None + file_nid = _make_id(str_path) + + # Ensure the file node exists (tree-sitter pass may have emitted it) + if file_nid not in seen_ids: + _add_node(file_nid, path.name, 1) + + for lineno, line_text in enumerate(lines_text, start=1): + cm = class_re.match(line_text) + if cm: + class_name = cm.group(1) + class_nid = _make_id(stem, class_name) + _add_node(class_nid, class_name, lineno) + _add_edge(file_nid, class_nid, "contains", lineno) + current_class_nid = class_nid + continue + + if current_class_nid is None: + continue + + fm = feature_re.match(line_text) + if fm: + method_name = fm.group(1) or fm.group(2) + method_label = f'"{method_name}"' + method_nid = _make_id(current_class_nid, method_name) + _add_node(method_nid, method_label, lineno) + _add_edge(current_class_nid, method_nid, "method", lineno) + continue + + pm = plain_method_re.match(line_text) + if pm: + method_name = pm.group(1) + if method_name not in ("if", "while", "for", "switch", "catch"): + method_label = f".{method_name}()" + method_nid = _make_id(current_class_nid, method_name) + _add_node(method_nid, method_label, lineno) + _add_edge(current_class_nid, method_nid, "method", lineno) + + return {"nodes": nodes, "edges": edges} + + +def extract_groovy(path: Path) -> dict: + """Extract classes, methods, constructors, and imports from a .groovy/.gradle file. + + Falls back to a regex-based Spock extractor when tree-sitter-groovy cannot parse + ``def "feature name"()`` methods (common in Spock specification classes). + """ + result = _extract_generic(path, _GROOVY_CONFIG) + if _is_spock_file(path, result): + result = _extract_spock_fallback(path, result) + return result + + def extract_c(path: Path) -> dict: """Extract functions and includes from a .c/.h file.""" return _extract_generic(path, _C_CONFIG) @@ -4011,6 +4139,8 @@ _DISPATCH: dict[str, Any] = { ".go": extract_go, ".rs": extract_rust, ".java": extract_java, + ".groovy": extract_groovy, + ".gradle": extract_groovy, ".c": extract_c, ".h": extract_c, ".cpp": extract_cpp, @@ -4367,7 +4497,7 @@ def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | N return [target] _EXTENSIONS = { ".py", ".js", ".ts", ".tsx", ".go", ".rs", - ".java", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", + ".java", ".groovy", ".gradle", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".rb", ".cs", ".kt", ".kts", ".scala", ".php", ".swift", ".lua", ".toc", ".zig", ".ps1", ".m", ".mm", diff --git a/pyproject.toml b/pyproject.toml index 80a9166dd..5dcc31f28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "tree-sitter-go", "tree-sitter-rust", "tree-sitter-java", + "tree-sitter-groovy>=0.1.2", "tree-sitter-c", "tree-sitter-cpp", "tree-sitter-ruby", diff --git a/tests/fixtures/sample.groovy b/tests/fixtures/sample.groovy new file mode 100644 index 000000000..7bef53eb8 --- /dev/null +++ b/tests/fixtures/sample.groovy @@ -0,0 +1,21 @@ +package pl.allegro.example + +import pl.allegro.logistics.Processor +import pl.allegro.logistics.util.Helper + +class SampleService { + Processor processor + + SampleService(Processor processor) { + this.processor = processor + } + + String process(String input) { + def result = processor.transform(input) + return Helper.clean(result) + } + + private void reset() { + processor.reset() + } +} diff --git a/tests/fixtures/sample_spock.groovy b/tests/fixtures/sample_spock.groovy new file mode 100644 index 000000000..3cf4153aa --- /dev/null +++ b/tests/fixtures/sample_spock.groovy @@ -0,0 +1,42 @@ +package pl.allegro.example + +import spock.lang.Specification + +class SampleSpec extends Specification { + + def setup() { + // common setup + } + + def "should process valid input"() { + given: + def input = "hello" + + when: + def result = input.toUpperCase() + + then: + result == "HELLO" + } + + def "should not change value when it's already correct"() { + given: + def value = "HELLO" + + when: + def result = value.toUpperCase() + + then: + result == value + } + + def "should handle #input and return #expected"() { + expect: + input.toUpperCase() == expected + + where: + input | expected + "hello" | "HELLO" + "world" | "WORLD" + } +} diff --git a/tests/test_languages.py b/tests/test_languages.py index b2460d51c..a9118afc4 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -6,6 +6,7 @@ from graphify.extract import ( extract_java, extract_c, extract_cpp, extract_ruby, extract_csharp, extract_kotlin, extract_scala, extract_php, extract_swift, extract_go, extract_julia, extract_js, extract_fortran, + extract_groovy, ) FIXTURES = Path(__file__).parent / "fixtures" @@ -853,3 +854,70 @@ def test_ts_static_template_literal_resolved(): targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"} assert any("statichelper" in t.lower() for t in targets), \ f"Static template literal import not resolved: {targets}" + + +# ── Groovy ─────────────────────────────────────────────────────────────────── + + +def test_groovy_no_error(): + r = extract_groovy(FIXTURES / "sample.groovy") + assert "error" not in r + + +def test_groovy_finds_class(): + r = extract_groovy(FIXTURES / "sample.groovy") + assert any("SampleService" in l for l in _labels(r)) + + +def test_groovy_finds_methods(): + r = extract_groovy(FIXTURES / "sample.groovy") + labels = _labels(r) + assert any("process" in l for l in labels) + assert any("reset" in l for l in labels) + + +def test_groovy_finds_imports(): + r = extract_groovy(FIXTURES / "sample.groovy") + assert "imports" in _relations(r) + + +def test_groovy_import_edges_have_import_context(): + r = extract_groovy(FIXTURES / "sample.groovy") + import_edges = _edges_with_relation(r, "imports", "imports_from") + assert import_edges + assert all(e.get("context") == "import" for e in import_edges) + + +def test_groovy_no_dangling_edges(): + r = extract_groovy(FIXTURES / "sample.groovy") + node_ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + assert e["source"] in node_ids + + +def test_groovy_spock_finds_class(): + r = extract_groovy(FIXTURES / "sample_spock.groovy") + assert any("SampleSpec" in l for l in _labels(r)) + + +def test_groovy_spock_finds_feature_methods(): + r = extract_groovy(FIXTURES / "sample_spock.groovy") + feature_labels = [l for l in _labels(r) if l.startswith('"')] + assert len(feature_labels) >= 2 + + +def test_groovy_spock_finds_method_with_apostrophe(): + r = extract_groovy(FIXTURES / "sample_spock.groovy") + assert any("it's" in l for l in _labels(r)) + + +def test_groovy_spock_preserves_import_edges(): + r = extract_groovy(FIXTURES / "sample_spock.groovy") + assert "imports" in _relations(r) + + +def test_groovy_spock_no_dangling_edges(): + r = extract_groovy(FIXTURES / "sample_spock.groovy") + node_ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + assert e["source"] in node_ids