mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-27 07:55:43 +00:00
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:
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+21
@@ -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()
|
||||
}
|
||||
}
|
||||
Vendored
+42
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user