feat: add Groovy and Spock support

- Register .groovy and .gradle in CODE_EXTENSIONS, _DISPATCH, and collect_files
- Add _GROOVY_CONFIG (reuses Java import handler)
- Add regex-based _extract_spock_fallback for Spock spec files where
  tree-sitter-groovy wraps the body in ERROR nodes due to def-string methods
- _is_spock_file detects via regex scan (def "...") instead of node-label
  heuristic, avoiding false negatives on classes whose name differs from stem
- Fallback retains only file node + import edges from tree-sitter pass to
  prevent orphaned constructor/method nodes
- Add tree-sitter-groovy>=0.1.2 dependency
- Add 11 tests covering plain Groovy and Spock paths, including apostrophe
  in feature method names
This commit is contained in:
Mikołaj Cekut
2026-05-05 12:15:15 +02:00
parent ee85bbfbfd
commit dc69020a47
6 changed files with 264 additions and 2 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', '.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'}
+131 -1
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(),
@@ -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",
+1
View File
@@ -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",
+21
View File
@@ -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()
}
}
+42
View File
@@ -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"
}
}
+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"
@@ -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