From 90bc29fd0032baf0b0dd73a8e9b13e99dd8b52ed Mon Sep 17 00:00:00 2001 From: ozdemirsarman Date: Wed, 15 Jul 2026 18:22:55 +0000 Subject: [PATCH] feat(extractors): add GDScript (.gd) + Godot scene/resource (.tscn/.tres/project.godot) extraction --- README.md | 2 +- graphify/detect.py | 2 +- graphify/extract.py | 6 + graphify/extractors/__init__.py | 4 + graphify/extractors/gdscript.py | 376 +++++++++++++++++++++++++++++ graphify/extractors/godot_scene.py | 231 ++++++++++++++++++ pyproject.toml | 8 + tests/test_gdscript.py | 119 +++++++++ tests/test_godot_scene.py | 91 +++++++ 9 files changed, 837 insertions(+), 2 deletions(-) create mode 100644 graphify/extractors/gdscript.py create mode 100644 graphify/extractors/godot_scene.py create mode 100644 tests/test_gdscript.py create mode 100644 tests/test_godot_scene.py diff --git a/README.md b/README.md index 81685cf72..e5de47d93 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml .gd .tscn .tres` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.gd` (GDScript) requires `uv tool install graphifyy[godot]`; `.tscn`/`.tres`/`project.godot` (Godot scenes, resources, autoloads, signal wires) need no grammar; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | | Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements | diff --git a/graphify/detect.py b/graphify/detect.py index 1c1c27c3e..4c2b2743b 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -27,7 +27,7 @@ class FileType(str, Enum): _MANIFEST_PATH = str(out_path("manifest.json")) -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.gd', '.tscn', '.tres', '.godot'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.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 12a4f108e..94c5a6c60 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -38,6 +38,8 @@ ) from graphify.extractors.dart import extract_dart # noqa: F401 from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm # noqa: F401 +from graphify.extractors.gdscript import extract_gdscript # noqa: F401 +from graphify.extractors.godot_scene import extract_godot_scene # noqa: F401 from graphify.extractors.elixir import extract_elixir # noqa: F401 from graphify.extractors.fortran import _cpp_preprocess, extract_fortran # noqa: F401 from graphify.extractors.go import extract_go # noqa: F401 @@ -3890,6 +3892,10 @@ def add_existing_edge(edge: dict) -> None: ".svelte": extract_svelte, ".astro": extract_astro, ".dart": extract_dart, + ".gd": extract_gdscript, + ".tscn": extract_godot_scene, + ".tres": extract_godot_scene, + ".godot": extract_godot_scene, ".v": extract_verilog, ".sv": extract_verilog, ".svh": extract_verilog, diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..773b75c0e 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -17,7 +17,9 @@ from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm from graphify.extractors.elixir import extract_elixir from graphify.extractors.fortran import extract_fortran +from graphify.extractors.gdscript import extract_gdscript from graphify.extractors.go import extract_go +from graphify.extractors.godot_scene import extract_godot_scene from graphify.extractors.json_config import extract_json from graphify.extractors.julia import extract_julia from graphify.extractors.markdown import extract_markdown @@ -45,7 +47,9 @@ "dmm": extract_dmm, "elixir": extract_elixir, "fortran": extract_fortran, + "gdscript": extract_gdscript, "go": extract_go, + "godot_scene": extract_godot_scene, "json": extract_json, "julia": extract_julia, "lazarus_form": extract_lazarus_form, diff --git a/graphify/extractors/gdscript.py b/graphify/extractors/gdscript.py new file mode 100644 index 000000000..099e73b77 --- /dev/null +++ b/graphify/extractors/gdscript.py @@ -0,0 +1,376 @@ +"""GDScript (Godot Engine) extractor. + +Parses ``.gd`` files with tree-sitter-gdscript and emits Graphify's +node/edge dicts. Captures: + +* ``class_name X`` / inner ``class X`` -> class node +* ``extends Base`` / ``extends "res://x.gd"`` -> ``extends`` edge +* ``func f(): ...`` -> function node + ``defines`` edge +* generic ``foo()`` / ``obj.method()`` calls inside a body -> ``calls`` edges +* ``Autoload.method()`` -> ``calls`` edge resolved to the function in the + autoload's script (autoload map read from ``project.godot``) +* ``signal s(args)`` -> signal node + ``declares`` edge +* ``emit_signal("s")`` / ``s.emit()`` -> ``emits`` edge +* ``s.connect(handler)`` -> ``connects`` edge +* ``preload("res://y.gd")`` / ``load("res://y.gd")`` -> ``imports`` edge + +Depends on the ``tree_sitter_gdscript`` grammar (Godot extra). If the grammar +is not installed the extractor degrades to a bare file node so the pipeline +never crashes. +""" +from __future__ import annotations + +import re + +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id + +# Cache of resolved [autoload] maps keyed by project root, so project.godot is +# parsed once per worker process rather than per .gd file. +_AUTOLOAD_CACHE: dict[str, dict[str, Path]] = {} +_AUTOLOAD_SECTION_RE = re.compile(r'^\s*\[(?P\w+)\]') + + +def _load_gdscript_parser(): + """Return a tree-sitter Parser for GDScript, or None if no grammar is available. + + Tries grammar sources in order of preference: + 1. ``tree_sitter_gdscript`` — the standalone grammar package (offline, + mirrors how every other language is wired here). + 2. ``tree_sitter_language_pack`` — bundled fallback for environments that + install the pack instead of the standalone grammar. + """ + try: + from tree_sitter import Language, Parser + except Exception: + return None + # 1) standalone grammar package + try: + import tree_sitter_gdscript as tsg + return Parser(Language(tsg.language())) + except Exception: + pass + # 2) language-pack fallback + try: + from tree_sitter_language_pack import get_language + return Parser(get_language("gdscript")) + except Exception: + pass + return None + + +def _txt(node, source: bytes) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", "replace") + + +def _loc(node) -> str: + return f"L{node.start_point[0] + 1}" + + +def _field_ident(node, source: bytes) -> str | None: + """Return the text of a node's ``name`` field (an identifier).""" + n = node.child_by_field_name("name") + if n is not None: + return _txt(n, source) + return None + + +def _first_named(node, types: set[str]): + for c in node.children: + if c.is_named and c.type in types: + return c + return None + + +def _strip_quotes(s: str) -> str: + s = s.strip() + if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]: + return s[1:-1] + return s + + +def _resolve_res(res_path: str, path: Path) -> Path | None: + """Resolve a ``res://`` path to a real file relative to the project root. + + The project root is the nearest ancestor containing ``project.godot``; + fall back to the file's own directory when none is found. + """ + if not res_path.startswith("res://"): + return None + rel = res_path[len("res://"):] + root = path.parent + for parent in [path.parent, *path.parents]: + if (parent / "project.godot").exists(): + root = parent + break + candidate = (root / rel) + try: + return candidate.resolve() + except Exception: + return candidate + + +def _autoload_map(path: Path) -> dict[str, Path]: + """Return ``{AutoloadName: resolved script Path}`` from the project's + ``project.godot`` ``[autoload]`` section, so that ``Autoload.method()`` calls + can resolve to the real function node in the autoload's script. + + Only ``.gd`` autoloads are mapped. Result is cached per project root. + """ + root = None + for parent in [path.parent, *path.parents]: + if (parent / "project.godot").exists(): + root = parent + break + if root is None: + return {} + key = str(root) + cached = _AUTOLOAD_CACHE.get(key) + if cached is not None: + return cached + + amap: dict[str, Path] = {} + try: + text = (root / "project.godot").read_text(encoding="utf-8", errors="replace") + except OSError: + _AUTOLOAD_CACHE[key] = amap + return amap + + section: str | None = None + for line in text.splitlines(): + m = _AUTOLOAD_SECTION_RE.match(line) + if m: + section = m.group("name") + continue + if section != "autoload": + continue + s = line.strip() + if not s or "=" not in s: + continue + name, _, val = s.partition("=") + name = name.strip() + raw = val.strip().strip('"') + res = raw[1:] if raw.startswith("*") else raw # '*' = enabled singleton + resolved = _resolve_res(res, path) if res.endswith(".gd") else None + if name and resolved is not None: + amap[name] = resolved + _AUTOLOAD_CACHE[key] = amap + return amap + + +def extract_gdscript(path: Path) -> dict: + parser = _load_gdscript_parser() + try: + raw = path.read_bytes() + except OSError: + return {"error": f"cannot read {path}"} + + stem = _file_stem(path) + file_nid = _make_id(str(path)) + nodes: list[dict] = [{ + "id": file_nid, "label": path.name, "file_type": "code", + "source_file": str(path), "source_location": None, + }] + edges: list[dict] = [] + defined: set[str] = {file_nid} + + def add_node(nid: str, label: str, source_location: str | None = None) -> None: + if nid not in defined: + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str(path), "source_location": source_location}) + defined.add(nid) + + def add_edge(src: str, tgt: str, relation: str, location: str | None = None, + context: str | None = None) -> None: + edge = {"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "confidence_score": 1.0, + "source_file": str(path), "source_location": location, "weight": 1.0} + if context: + edge["context"] = context + edges.append(edge) + + if parser is None: + # grammar unavailable: bare file node keeps the pipeline alive + return {"nodes": nodes, "edges": edges} + + tree = parser.parse(raw) + root = tree.root_node + autoloads = _autoload_map(path) # AutoloadName -> resolved script Path + + # ---- pre-scan: local function names so calls resolve to real defs ------- + local_funcs: dict[str, str] = {} + + def _prescan(node) -> None: + for c in node.children: + if c.type in ("function_definition", "constructor_definition"): + fname = _field_ident(c, raw) or ("_init" if c.type == "constructor_definition" else None) + if fname: + local_funcs.setdefault(fname, _make_id(stem, fname)) + _prescan(c) + + _prescan(root) + + # ---- top-level class identity ------------------------------------------- + owner_nid = file_nid + owner_label = path.name + signals: dict[str, str] = {} # signal name -> node id (script-level) + + cn = _first_named(root, {"class_name_statement"}) + if cn is not None: + name = _field_ident(cn, raw) + if name: + owner_label = name + owner_nid = _make_id(stem, name) + add_node(owner_nid, name, _loc(cn)) + add_edge(file_nid, owner_nid, "defines", _loc(cn)) + + # ---- extends ------------------------------------------------------------- + ext = _first_named(root, {"extends_statement"}) + if ext is not None: + type_node = _first_named(ext, {"type"}) + base_txt = _txt(type_node, raw).strip() if type_node is not None else _txt(ext, raw).replace("extends", "", 1).strip() + if base_txt: + res = None + if base_txt.startswith(("\"", "'")): + res = _resolve_res(_strip_quotes(base_txt), path) + if res is not None: + tgt = _make_id(str(res)) + add_node(tgt, res.name) + else: + tgt = _make_id(base_txt) + add_node(tgt, base_txt) + add_edge(owner_nid, tgt, "extends", _loc(ext)) + + # ---- signals (script level) --------------------------------------------- + for sig in [c for c in root.children if c.is_named and c.type == "signal_statement"]: + sname = _field_ident(sig, raw) + if sname: + snid = _make_id(stem, "signal:" + sname) + add_node(snid, sname + " (signal)", _loc(sig)) + add_edge(owner_nid, snid, "declares", _loc(sig)) + signals[sname] = snid + + # ---- functions and their call bodies ------------------------------------ + def handle_call(call_node, func_nid: str) -> None: + """A bare ``call`` node: callee is the first identifier child.""" + callee = None + for c in call_node.children: + if c.type == "identifier": + callee = _txt(c, raw) + break + if c.type in ("attribute",): + break + if callee is None: + return + args = call_node.child_by_field_name("arguments") + if callee in ("preload", "load") and args is not None: + for a in args.children: + if a.type == "string": + res = _resolve_res(_strip_quotes(_txt(a, raw)), path) + if res is not None: + tgt = _make_id(str(res)) + add_node(tgt, res.name) + add_edge(file_nid, tgt, "imports", _loc(call_node), context="preload") + break + return + if callee == "emit_signal" and args is not None: + for a in args.children: + if a.type == "string": + sname = _strip_quotes(_txt(a, raw)) + snid = signals.get(sname) or _make_id(stem, "signal:" + sname) + add_node(snid, sname + " (signal)") + add_edge(func_nid, snid, "emits", _loc(call_node)) + break + return + if callee in local_funcs: + tgt = local_funcs[callee] + else: + tgt = _make_id(callee) + add_node(tgt, callee + "()") + add_edge(func_nid, tgt, "calls", _loc(call_node)) + + def handle_attribute_call(attr_node, func_nid: str) -> None: + """``receiver.method(args)`` -> attribute( identifier, attribute_call ).""" + recv = None + acall = None + for c in attr_node.children: + if c.type == "identifier" and recv is None: + recv = _txt(c, raw) + elif c.type == "attribute_call": + acall = c + if acall is None: + return + method = None + for c in acall.children: + if c.type == "identifier": + method = _txt(c, raw) + break + if method is None: + return + if method == "connect" and recv in signals: + acargs = acall.child_by_field_name("arguments") + handler = None + if acargs is not None: + for a in acargs.children: + if a.type == "identifier": + handler = _txt(a, raw) + break + if handler: + if handler in local_funcs: + htgt = local_funcs[handler] + else: + htgt = _make_id(handler) + add_node(htgt, handler + "()") + add_edge(signals[recv], htgt, "connects", _loc(attr_node)) + return + if method == "emit" and recv in signals: + add_edge(func_nid, signals[recv], "emits", _loc(attr_node)) + return + # ``Autoload.method()`` -> resolve to the real function node in the + # autoload's script (its id matches _make_id(_file_stem(script), method)). + if recv in autoloads: + tgt = _make_id(_file_stem(autoloads[recv]), method) + add_edge(func_nid, tgt, "calls", _loc(attr_node), context=recv) + return + tgt = _make_id(method) + add_node(tgt, method + "()") + add_edge(func_nid, tgt, "calls", _loc(attr_node), context=(recv or "")) + + def walk_body(node, func_nid: str) -> None: + for c in node.children: + if c.type == "function_definition": + continue # nested funcs handled separately + if c.type == "call": + handle_call(c, func_nid) + elif c.type == "attribute": + # attribute may itself contain an attribute_call (method call) + if any(k.type == "attribute_call" for k in c.children): + handle_attribute_call(c, func_nid) + walk_body(c, func_nid) + + def collect_functions(container, owner: str) -> None: + for c in container.children: + if c.type in ("function_definition", "constructor_definition"): + fname = _field_ident(c, raw) or ("_init" if c.type == "constructor_definition" else None) + if not fname: + continue + fnid = _make_id(stem, fname) + add_node(fnid, fname + "()", _loc(c)) + add_edge(owner, fnid, "defines", _loc(c)) + body = c.child_by_field_name("body") + if body is not None: + walk_body(body, fnid) + elif c.type == "class_definition": + iname = _field_ident(c, raw) + inid = _make_id(stem, iname) if iname else owner + if iname: + add_node(inid, iname, _loc(c)) + add_edge(owner, inid, "defines", _loc(c)) + cbody = _first_named(c, {"class_body"}) + if cbody is not None: + collect_functions(cbody, inid) + + collect_functions(root, owner_nid) + + return {"nodes": nodes, "edges": edges} diff --git a/graphify/extractors/godot_scene.py b/graphify/extractors/godot_scene.py new file mode 100644 index 000000000..84f2444da --- /dev/null +++ b/graphify/extractors/godot_scene.py @@ -0,0 +1,231 @@ +"""Godot scene / resource / project extractor. + +Parses Godot's INI-like text formats without a tree-sitter grammar: + +* ``.tscn`` (PackedScene) and ``.tres`` (Resource): + - ``[ext_resource type="Script" path="res://x.gd"]`` -> ``attaches_script`` edge + - ``[ext_resource type="PackedScene" path="res://y.tscn"]`` -> ``instances`` edge + - ``[node name="N" type="T" parent="P"]`` -> scene-tree node + ``child_of`` edge + - ``script = ExtResource("id")`` under a node -> that node's ``attaches_script`` edge + - ``[connection signal="s" from="A" to="B" method="m"]`` -> ``connects`` edge, + resolved to the target node's script function when possible + +* ``project.godot``: + - ``[autoload]`` entries -> global singleton node + ``autoload`` edge to the script + - ``run/main_scene`` -> ``main_scene`` edge + +Godot text formats are line-oriented and stable, so a small hand parser is +more robust here than a grammar (and adds no dependency). +""" +from __future__ import annotations + +import re + +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id + +_SECTION_RE = re.compile(r'^\[(?P[a-z_]+)(?P[^\]]*)\]\s*$') +_ATTR_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"') +_EXTRES_CALL_RE = re.compile(r'ExtResource\(\s*"?([^")]+)"?\s*\)') + + +def _project_root(path: Path) -> Path: + for parent in [path.parent, *path.parents]: + if (parent / "project.godot").exists(): + return parent + return path.parent + + +def _resolve_res(res_path: str, root: Path) -> Path | None: + if not res_path.startswith("res://"): + return None + candidate = root / res_path[len("res://"):] + try: + return candidate.resolve() + except Exception: + return candidate + + +def _parse_attrs(attr_str: str) -> dict: + return {k: v for k, v in _ATTR_RE.findall(attr_str)} + + +def extract_godot_scene(path: Path) -> dict: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {"error": f"cannot read {path}"} + + if path.name == "project.godot": + return _extract_project(path, text) + + root = _project_root(path) + file_nid = _make_id(str(path)) + nodes: list[dict] = [{ + "id": file_nid, "label": path.name, "file_type": "code", + "source_file": str(path), "source_location": None, + }] + edges: list[dict] = [] + defined: set[str] = {file_nid} + + def add_node(nid: str, label: str, loc: str | None = None) -> None: + if nid not in defined: + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str(path), "source_location": loc}) + defined.add(nid) + + def add_edge(src: str, tgt: str, relation: str, loc: str | None = None, + context: str | None = None) -> None: + e = {"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "confidence_score": 1.0, + "source_file": str(path), "source_location": loc, "weight": 1.0} + if context: + e["context"] = context + edges.append(e) + + ext_resources: dict[str, dict] = {} # id -> {type, path, resolved, nid} + root_script_stem: str | None = None # stem of the script on the "." node + node_scripts: dict[str, str] = {} # node path -> script stem + + lines = text.splitlines() + current_kind: str | None = None + current_attrs: dict = {} + current_node_path: str | None = None + + for i, line in enumerate(lines): + loc = f"L{i + 1}" + m = _SECTION_RE.match(line) + if m: + current_kind = m.group("kind") + current_attrs = _parse_attrs(m.group("attrs")) + current_node_path = None + + if current_kind == "ext_resource": + rid = current_attrs.get("id", "") + rtype = current_attrs.get("type", "") + rpath = current_attrs.get("path", "") + resolved = _resolve_res(rpath, root) + info = {"type": rtype, "path": rpath, "resolved": resolved} + ext_resources[rid] = info + if resolved is not None: + tgt = _make_id(str(resolved)) + add_node(tgt, resolved.name) + if rpath.endswith(".gd") or rtype == "Script": + add_edge(file_nid, tgt, "attaches_script", loc) + elif rpath.endswith(".tscn") or rtype == "PackedScene": + add_edge(file_nid, tgt, "instances", loc) + else: + add_edge(file_nid, tgt, "uses_resource", loc, context=rtype or None) + + elif current_kind == "node": + name = current_attrs.get("name", "") + parent = current_attrs.get("parent") + if parent is None: + current_node_path = "." # scene root + elif parent == ".": + current_node_path = name + else: + current_node_path = f"{parent}/{name}" + + elif current_kind == "connection": + sig = current_attrs.get("signal", "") + frm = current_attrs.get("from", "") + to = current_attrs.get("to", "") + method = current_attrs.get("method", "") + if method: + tgt_stem = node_scripts.get(to) + if tgt_stem is None and to == ".": + tgt_stem = root_script_stem + if tgt_stem is not None: + tgt = _make_id(tgt_stem, method) + else: + tgt = _make_id(method) + add_node(tgt, method + "()") + add_edge(file_nid, tgt, "connects", loc, + context=f"{sig} from {frm}") + continue + + # property line inside a [node] block: script = ExtResource("id") + if current_kind == "node" and current_node_path is not None: + stripped = line.strip() + if stripped.startswith("script ") or stripped.startswith("script="): + cm = _EXTRES_CALL_RE.search(stripped) + if cm: + rid = cm.group(1) + info = ext_resources.get(rid) + if info and info.get("resolved") is not None: + resolved = info["resolved"] + tgt = _make_id(str(resolved)) + add_node(tgt, resolved.name) + add_edge(file_nid, tgt, "attaches_script", loc, + context=current_node_path) + stem = _file_stem(resolved) + node_scripts[current_node_path] = stem + if current_node_path == ".": + root_script_stem = stem + + return {"nodes": nodes, "edges": edges} + + +def _extract_project(path: Path, text: str) -> dict: + root = path.parent + file_nid = _make_id(str(path)) + nodes: list[dict] = [{ + "id": file_nid, "label": "project.godot", "file_type": "code", + "source_file": str(path), "source_location": None, + }] + edges: list[dict] = [] + defined: set[str] = {file_nid} + + def add_node(nid: str, label: str, loc: str | None = None) -> None: + if nid not in defined: + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str(path), "source_location": loc}) + defined.add(nid) + + def add_edge(src: str, tgt: str, relation: str, loc: str | None = None, + context: str | None = None) -> None: + e = {"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "confidence_score": 1.0, + "source_file": str(path), "source_location": loc, "weight": 1.0} + if context: + e["context"] = context + edges.append(e) + + section: str | None = None + for i, line in enumerate(text.splitlines()): + loc = f"L{i + 1}" + s = line.strip() + if not s or s.startswith(";"): + continue + sm = _SECTION_RE.match(line) + if sm: + section = sm.group("kind") + continue + if "=" not in s: + continue + key, _, val = s.partition("=") + key = key.strip() + val = val.strip() + + if section == "autoload": + # GameState="*res://scripts/game_state.gd" + raw = val.strip().strip('"') + res = raw[1:] if raw.startswith("*") else raw + resolved = _resolve_res(res, root) + gid = _make_id("autoload", key) + add_node(gid, key + " (autoload)", loc) + add_edge(file_nid, gid, "autoload", loc) + if resolved is not None: + tgt = _make_id(str(resolved)) + add_node(tgt, resolved.name) + add_edge(gid, tgt, "script", loc) + elif key == "run/main_scene": + resolved = _resolve_res(val.strip('"'), root) + if resolved is not None: + tgt = _make_id(str(resolved)) + add_node(tgt, resolved.name) + add_edge(file_nid, tgt, "main_scene", loc) + + return {"nodes": nodes, "edges": edges} diff --git a/pyproject.toml b/pyproject.toml index 2e5340d39..6257dbce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,14 @@ pascal = ["tree-sitter-pascal"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] +# GDScript (Godot Engine) AST extraction uses tree-sitter-gdscript. The .gd +# extractor degrades to a bare file node when the grammar is absent (like sql / +# pascal), so this stays optional. The standalone `tree-sitter-gdscript` wheel is +# not yet on PyPI; until it is, the extractor also accepts a grammar provided by +# `tree-sitter-language-pack`. The `.tscn`/`.tres`/`project.godot` scene extractor +# needs NO grammar (line-based parser), so scene/autoload/signal-wire edges work +# out of the box. See the PR discussion for the grammar-packaging decision. +godot = ["tree-sitter-gdscript"] all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"] [project.scripts] diff --git a/tests/test_gdscript.py b/tests/test_gdscript.py new file mode 100644 index 000000000..261b2a778 --- /dev/null +++ b/tests/test_gdscript.py @@ -0,0 +1,119 @@ +import tempfile +import textwrap +import unittest +from pathlib import Path + +from graphify.extract import extract_gdscript, _make_id, _file_stem +from graphify.extractors.gdscript import _load_gdscript_parser + +_HAS_GRAMMAR = _load_gdscript_parser() is not None + + +def _rels(result): + return {e["relation"] for e in result["edges"]} + + +def _edge(result, relation): + return [e for e in result["edges"] if e["relation"] == relation] + + +@unittest.skipUnless(_HAS_GRAMMAR, "tree-sitter-gdscript grammar not installed") +class TestGDScript(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / "project.godot").write_text("config_version=5\n") + + def tearDown(self): + self.tmp.cleanup() + + def _write(self, name, body): + p = self.root / name + p.write_text(textwrap.dedent(body)) + return p + + def test_class_extends_functions_signals_calls(self): + (self.root / "audio.gd").write_text("extends Node\nclass_name AudioManager\n") + p = self._write("enemy.gd", """ + class_name Enemy + extends CharacterBody2D + + signal died(reason) + + func _ready(): + died.connect(_on_died) + sprite.play("idle") + + func take_damage(amount): + if amount > 0: + emit_signal("died", "killed") + die() + + func die(): + var fx = preload("res://audio.gd") + queue_free() + + func _on_died(reason): + print(reason) + """) + r = extract_gdscript(p) + rels = _rels(r) + for expected in ("defines", "extends", "declares", "emits", "connects", "calls", "imports"): + self.assertIn(expected, rels, f"missing relation {expected}") + + # class node id + stem = _file_stem(p) + enemy_nid = _make_id(stem, "Enemy") + labels = {n["id"]: n["label"] for n in r["nodes"]} + self.assertEqual(labels.get(enemy_nid), "Enemy") + + # extends target is the base type + ext = _edge(r, "extends")[0] + self.assertEqual(labels.get(ext["target"]), "CharacterBody2D") + + # local call resolves to the DEFINED die() (same id, no orphan anchor) + die_nid = _make_id(stem, "die") + calls_targets = {e["target"] for e in _edge(r, "calls")} + self.assertIn(die_nid, calls_targets) + + # signal connect resolves handler to the local function + on_died_nid = _make_id(stem, "_on_died") + conn = _edge(r, "connects")[0] + self.assertEqual(conn["target"], on_died_nid) + + # preload resolves res:// to the real file node id + audio_nid = _make_id(str((self.root / "audio.gd").resolve())) + imports_targets = {e["target"] for e in _edge(r, "imports")} + self.assertIn(audio_nid, imports_targets) + + def test_autoload_method_call_resolves_to_script_function(self): + # project.godot registers Analytics as an autoload -> analytics.gd + (self.root / "project.godot").write_text( + 'config_version=5\n\n[autoload]\nAnalytics="*res://analytics.gd"\n' + ) + analytics = self.root / "analytics.gd" + analytics.write_text("extends Node\nfunc track(name):\n\tpass\n") + caller = self._write("player.gd", """ + extends Node + func attack(): + Analytics.track("hit") + """) + r = extract_gdscript(caller) + # the call must target analytics.gd's track function node, NOT a bare anchor + want = _make_id(_file_stem(analytics), "track") + resolved = [e for e in _edge(r, "calls") if e.get("context") == "Analytics"] + self.assertTrue(resolved, "no autoload-resolved call edge emitted") + self.assertEqual(resolved[0]["target"], want) + # a bare `track()` anchor must NOT have been created + self.assertFalse(any(n["label"] == "track()" for n in r["nodes"])) + + def test_missing_grammar_is_graceful(self): + # Even with a grammar present, an empty file yields just the file node. + p = self._write("empty.gd", "") + r = extract_gdscript(p) + self.assertTrue(any(n["label"] == "empty.gd" for n in r["nodes"])) + self.assertNotIn("error", r) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_godot_scene.py b/tests/test_godot_scene.py new file mode 100644 index 000000000..24a5ce7ad --- /dev/null +++ b/tests/test_godot_scene.py @@ -0,0 +1,91 @@ +import tempfile +import textwrap +import unittest +from pathlib import Path + +from graphify.extract import extract_godot_scene, _make_id, _file_stem + + +def _edges(result, relation): + return [e for e in result["edges"] if e["relation"] == relation] + + +class TestGodotScene(unittest.TestCase): + """The .tscn/.tres/project.godot extractor needs no grammar.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / "scripts").mkdir() + (self.root / "scenes").mkdir() + (self.root / "project.godot").write_text("config_version=5\n") + (self.root / "scripts" / "enemy.gd").write_text( + "class_name Enemy\nextends CharacterBody2D\nfunc take_damage(a):\n\tpass\n" + ) + (self.root / "scripts" / "game_state.gd").write_text("extends Node\n") + (self.root / "scenes" / "Bullet.tscn").write_text( + '[gd_scene format=3 uid="uid://bul"]\n\n[node name="Bullet" type="Area2D"]\n' + ) + + def tearDown(self): + self.tmp.cleanup() + + def _write(self, name, body): + p = self.root / name + p.write_text(textwrap.dedent(body)) + return p + + def test_scene_script_subscene_and_connection(self): + p = self._write("scenes/Main.tscn", """ + [gd_scene load_steps=3 format=3 uid="uid://abc"] + + [ext_resource type="Script" path="res://scripts/enemy.gd" id="1_e"] + [ext_resource type="PackedScene" path="res://scenes/Bullet.tscn" id="2_b"] + + [node name="Enemy" type="CharacterBody2D"] + script = ExtResource("1_e") + + [node name="Hitbox" type="Area2D" parent="."] + + [connection signal="body_entered" from="Hitbox" to="." method="take_damage"] + """) + r = extract_godot_scene(p) + + enemy_gd = _make_id(str((self.root / "scripts" / "enemy.gd").resolve())) + bullet = _make_id(str((self.root / "scenes" / "Bullet.tscn").resolve())) + + attaches = {e["target"] for e in _edges(r, "attaches_script")} + self.assertIn(enemy_gd, attaches) + + instances = {e["target"] for e in _edges(r, "instances")} + self.assertIn(bullet, instances) + + # the connection method resolves to the ROOT script's function node id, + # i.e. the same id the gdscript extractor emits for take_damage() + stem = _file_stem((self.root / "scripts" / "enemy.gd").resolve()) + take_damage_nid = _make_id(stem, "take_damage") + conn_targets = {e["target"] for e in _edges(r, "connects")} + self.assertIn(take_damage_nid, conn_targets) + + def test_project_godot_autoloads_and_main_scene(self): + p = self._write("project.godot", """ + config_version=5 + + [application] + run/main_scene="res://scenes/Main.tscn" + + [autoload] + GameState="*res://scripts/game_state.gd" + """) + r = extract_godot_scene(p) + + self.assertTrue(_edges(r, "autoload"), "no autoload edge emitted") + self.assertTrue(_edges(r, "main_scene"), "no main_scene edge emitted") + + gs = _make_id(str((self.root / "scripts" / "game_state.gd").resolve())) + script_targets = {e["target"] for e in _edges(r, "script")} + self.assertIn(gs, script_targets) + + +if __name__ == "__main__": + unittest.main()