Merge PR #732: feat: add Groovy and Spock support

This commit is contained in:
Safi
2026-05-06 12:04:51 +01:00
6 changed files with 263 additions and 1 deletions
+1 -1
View File
@@ -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', '.luau', '.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', '.luau', '.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'}
+130
View File
@@ -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(),
@@ -1877,6 +1889,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)
@@ -4183,6 +4311,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,
+1
View File
@@ -21,6 +21,7 @@ dependencies = [
"tree-sitter-go",
"tree-sitter-rust",
"tree-sitter-java",
"tree-sitter-groovy",
"tree-sitter-c",
"tree-sitter-cpp",
"tree-sitter-ruby",
+21
View File
@@ -0,0 +1,21 @@
package com.nicklastrange.example
import com.nicklastrange.Processor
import com.nicklastrange.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()
}
}
+42
View File
@@ -0,0 +1,42 @@
package com.nicklastrange.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"
}
}
+68
View File
@@ -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"
@@ -896,3 +897,70 @@ def test_markdown_no_dangling_edges():
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids, f"Dangling source: {e}"
# ── 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