From 90bc29fd0032baf0b0dd73a8e9b13e99dd8b52ed Mon Sep 17 00:00:00 2001 From: ozdemirsarman Date: Wed, 15 Jul 2026 18:22:55 +0000 Subject: [PATCH 1/3] 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() From 69a5f8bc9dc6862974611c55cf7529d152d8bb05 Mon Sep 17 00:00:00 2001 From: ozdemirsarman Date: Sun, 26 Jul 2026 21:11:16 +0300 Subject: [PATCH 2/3] feat(extractors): source Godot grammars from tree-sitter-language-pack Address review (#1929): depend on tree-sitter-language-pack>=1.12.5,<1.13 (as #1836 does) instead of the unpublished tree-sitter-gdscript wheel. The .gd extractor loads the bundled gdscript grammar; the scene/resource extractor now uses the bundled godot_resource grammar, falling back to a dependency-free line parser when the extra is absent. --- README.md | 2 +- graphify/extractors/gdscript.py | 29 +-- graphify/extractors/godot_scene.py | 377 ++++++++++++++++++++--------- pyproject.toml | 18 +- tests/test_godot_scene.py | 93 ++++++- 5 files changed, 378 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index e5de47d93..560dff05e 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 .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) | +| 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) and `.tscn`/`.tres`/`project.godot` (Godot scenes, resources, autoloads, signal wires) use `uv tool install graphifyy[godot]` (grammars bundled via `tree-sitter-language-pack`); the scene/resource extractor still falls back to a dependency-free line parser when the extra is absent; `.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/extractors/gdscript.py b/graphify/extractors/gdscript.py index 099e73b77..2a3473e31 100644 --- a/graphify/extractors/gdscript.py +++ b/graphify/extractors/gdscript.py @@ -1,6 +1,6 @@ """GDScript (Godot Engine) extractor. -Parses ``.gd`` files with tree-sitter-gdscript and emits Graphify's +Parses ``.gd`` files with a tree-sitter GDScript grammar and emits Graphify's node/edge dicts. Captures: * ``class_name X`` / inner ``class X`` -> class node @@ -14,9 +14,9 @@ * ``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. +Depends on the GDScript grammar from ``tree-sitter-language-pack`` (the Godot +extra, ``graphify[godot]``). If the grammar is not installed the extractor +degrades to a bare file node so the pipeline never crashes. """ from __future__ import annotations @@ -36,25 +36,26 @@ 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. + 1. ``tree_sitter_language_pack`` — the Godot extra (``graphify[godot]``) + installs this; it bundles PrestonKnopp's GDScript grammar and is the + supported path since the standalone wheel is not on PyPI. + 2. ``tree_sitter_gdscript`` — the standalone grammar package, used + opportunistically if a user has it installed directly. """ try: from tree_sitter import Language, Parser except Exception: return None - # 1) standalone grammar package + # 1) language-pack (the Godot extra installs this) try: - import tree_sitter_gdscript as tsg - return Parser(Language(tsg.language())) + from tree_sitter_language_pack import get_parser + return get_parser("gdscript") except Exception: pass - # 2) language-pack fallback + # 2) standalone grammar package (opportunistic fallback) try: - from tree_sitter_language_pack import get_language - return Parser(get_language("gdscript")) + import tree_sitter_gdscript as tsg + return Parser(Language(tsg.language())) except Exception: pass return None diff --git a/graphify/extractors/godot_scene.py b/graphify/extractors/godot_scene.py index 84f2444da..319780d19 100644 --- a/graphify/extractors/godot_scene.py +++ b/graphify/extractors/godot_scene.py @@ -1,11 +1,12 @@ """Godot scene / resource / project extractor. -Parses Godot's INI-like text formats without a tree-sitter grammar: +Parses Godot's text formats for ``.tscn`` (PackedScene), ``.tres`` (Resource) +and ``project.godot`` and emits Graphify's node/edge dicts: -* ``.tscn`` (PackedScene) and ``.tres`` (Resource): +* ``.tscn`` / ``.tres``: - ``[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 + - ``[node name="N" type="T" parent="P"]`` -> scene-tree node - ``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 @@ -14,8 +15,15 @@ - ``[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). +Godot's scene / resource / project files all share one text format. When the +``godot_resource`` tree-sitter grammar is available (bundled in +``tree-sitter-language-pack``, the Godot extra) it is used for robust handling +of quoting, multi-line values and nested constructors. Both front ends lower a +file to the same intermediate list of ``_Block``s, which a single edge builder +consumes -- so the grammar path and the dependency-free line-parser fallback +produce identical nodes/edges. If the grammar is absent (or a file fails to +parse cleanly) the line parser takes over, so scene/autoload/signal-wire edges +keep working out of the box. """ from __future__ import annotations @@ -27,9 +35,31 @@ _SECTION_RE = re.compile(r'^\[(?P[a-z_]+)(?P[^\]]*)\]\s*$') _ATTR_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"') +_PROP_RE = re.compile(r'^\s*(?P[\w/]+)\s*=\s*(?P.+?)\s*$') _EXTRES_CALL_RE = re.compile(r'ExtResource\(\s*"?([^")]+)"?\s*\)') +class _Block: + """One header section (or the file preamble) in normalized form. + + ``kind`` is the section identifier (``ext_resource``, ``node``, + ``connection``, ``autoload`` ...) or ``None`` for top-level properties that + precede any section (e.g. ``config_version`` in ``project.godot``). + ``attrs`` are the header's ``key="value"`` pairs (unquoted). ``props`` are + the ``key = value`` lines inside the section as ``(key, raw_value, loc)``; + the raw value is kept verbatim so each builder applies its own unquoting / + ``ExtResource(...)`` parsing exactly as before. + """ + + __slots__ = ("kind", "attrs", "props", "loc") + + def __init__(self, kind, attrs, props, loc): + self.kind = kind + self.attrs = attrs + self.props = props + self.loc = loc + + def _project_root(path: Path) -> Path: for parent in [path.parent, *path.parents]: if (parent / "project.godot").exists(): @@ -51,15 +81,160 @@ def _parse_attrs(attr_str: str) -> dict: return {k: v for k, v in _ATTR_RE.findall(attr_str)} +# --------------------------------------------------------------------------- +# Grammar-based front end (tree-sitter-language-pack: "godot_resource") +# --------------------------------------------------------------------------- + +# Sentinel so a failed import is cached and not retried per file. +_RESOURCE_PARSER: object = "unset" + + +def _load_resource_parser(): + """Return a tree-sitter Parser for Godot resource text, or ``None``. + + The ``godot_resource`` grammar (PrestonKnopp, MIT) is bundled in + ``tree-sitter-language-pack`` (the Godot extra) and parses ``.tscn``, + ``.tres`` and ``project.godot`` alike. When the pack is not installed the + caller falls back to the line parser below. + """ + global _RESOURCE_PARSER + if _RESOURCE_PARSER != "unset": + return _RESOURCE_PARSER + parser = None + try: + from tree_sitter_language_pack import get_parser + parser = get_parser("godot_resource") + except Exception: + parser = None + _RESOURCE_PARSER = parser + return parser + + +def _node_text(node, src: bytes) -> str: + return src[node.start_byte:node.end_byte].decode("utf-8", "replace") + + +def _unquote(s: str) -> str: + if len(s) >= 2 and s[0] == '"' and s[-1] == '"': + return s[1:-1] + return s + + +def _key_value_nodes(node): + """First and last named children of an ``attribute`` / ``property`` node. + + Both grammar shapes are ``key = value`` with the key as the first named + child and the value as the last, so this covers both. + """ + named = [c for c in node.children if c.is_named] + if len(named) >= 2: + return named[0], named[-1] + if len(named) == 1: + return named[0], None + return None, None + + +def _blocks_from_grammar(text: str) -> list[_Block] | None: + parser = _load_resource_parser() + if parser is None: + return None + try: + tree = parser.parse(text.encode("utf-8")) + except Exception: + return None + if tree.root_node.has_error: + # A malformed file: defer to the tolerant line parser instead. + return None + + src = text.encode("utf-8") + blocks: list[_Block] = [] + preamble: list[tuple[str, str, str]] = [] + + for child in tree.root_node.children: + if child.type == "property": + k, v = _key_value_nodes(child) + if k is not None: + preamble.append((_node_text(k, src), + _node_text(v, src) if v is not None else "", + f"L{child.start_point[0] + 1}")) + elif child.type == "section": + kind: str | None = None + attrs: dict = {} + props: list[tuple[str, str, str]] = [] + for c in child.children: + if c.type == "identifier" and kind is None: + kind = _node_text(c, src) + elif c.type == "attribute": + k, v = _key_value_nodes(c) + if k is not None: + attrs[_node_text(k, src)] = ( + _unquote(_node_text(v, src)) if v is not None else "") + elif c.type == "property": + k, v = _key_value_nodes(c) + if k is not None: + props.append((_node_text(k, src), + _node_text(v, src) if v is not None else "", + f"L{c.start_point[0] + 1}")) + blocks.append(_Block(kind, attrs, props, f"L{child.start_point[0] + 1}")) + + if preamble: + blocks.insert(0, _Block(None, {}, preamble, "L1")) + return blocks + + +# --------------------------------------------------------------------------- +# Dependency-free line parser (fallback when the grammar is absent) +# --------------------------------------------------------------------------- + +def _blocks_from_lines(text: str) -> list[_Block]: + blocks: list[_Block] = [] + preamble = _Block(None, {}, [], "L1") + current: _Block | None = None + + for i, line in enumerate(text.splitlines()): + loc = f"L{i + 1}" + m = _SECTION_RE.match(line) + if m: + current = _Block(m.group("kind"), _parse_attrs(m.group("attrs")), [], loc) + blocks.append(current) + continue + stripped = line.strip() + if not stripped or stripped.startswith(";"): + continue + pm = _PROP_RE.match(line) + if pm: + target = current if current is not None else preamble + target.props.append((pm.group("key"), pm.group("val"), loc)) + + if preamble.props: + blocks.insert(0, preamble) + return blocks + + +def _blocks(text: str) -> list[_Block]: + blocks = _blocks_from_grammar(text) + if blocks is None: + blocks = _blocks_from_lines(text) + return blocks + + +# --------------------------------------------------------------------------- +# Shared edge builders +# --------------------------------------------------------------------------- + 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}"} + blocks = _blocks(text) if path.name == "project.godot": - return _extract_project(path, text) + return _build_project(path, blocks) + return _build_scene(path, blocks) + +def _build_scene(path: Path, blocks: list[_Block]) -> dict: root = _project_root(path) file_nid = _make_id(str(path)) nodes: list[dict] = [{ @@ -84,91 +259,79 @@ def add_edge(src: str, tgt: str, relation: str, loc: str | None = None, e["context"] = context edges.append(e) - ext_resources: dict[str, dict] = {} # id -> {type, path, resolved, nid} + ext_resources: dict[str, dict] = {} # id -> {type, path, resolved} 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 block in blocks: + kind = block.kind + loc = block.loc - 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: + if kind == "ext_resource": + rid = block.attrs.get("id", "") + rtype = block.attrs.get("type", "") + rpath = block.attrs.get("path", "") + resolved = _resolve_res(rpath, root) + ext_resources[rid] = {"type": rtype, "path": rpath, "resolved": resolved} + 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 kind == "node": + name = block.attrs.get("name", "") + parent = block.attrs.get("parent") + if parent is None: + node_path = "." # scene root + elif parent == ".": + node_path = name + else: + node_path = f"{parent}/{name}" + + for key, val, ploc in block.props: + if key != "script": + continue + cm = _EXTRES_CALL_RE.search(val) + if not cm: + continue + info = ext_resources.get(cm.group(1)) + if info and info.get("resolved") is not None: + resolved = info["resolved"] 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 + add_edge(file_nid, tgt, "attaches_script", ploc, + context=node_path) + stem = _file_stem(resolved) + node_scripts[node_path] = stem + if node_path == ".": + root_script_stem = stem + + elif kind == "connection": + sig = block.attrs.get("signal", "") + frm = block.attrs.get("from", "") + to = block.attrs.get("to", "") + method = block.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: - 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 + tgt = _make_id(method) + add_node(tgt, method + "()") + add_edge(file_nid, tgt, "connects", loc, + context=f"{sig} from {frm}") return {"nodes": nodes, "edges": edges} -def _extract_project(path: Path, text: str) -> dict: +def _build_project(path: Path, blocks: list[_Block]) -> dict: root = path.parent file_nid = _make_id(str(path)) nodes: list[dict] = [{ @@ -193,39 +356,25 @@ def add_edge(src: str, tgt: str, relation: str, loc: str | None = None, 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) + for block in blocks: + for key, val, loc in block.props: + if block.kind == "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().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 6257dbce5..76e5509cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,15 +80,15 @@ 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"] +# Godot (Engine) extraction. The standalone `tree-sitter-gdscript` wheel is not +# on PyPI, so the `.gd` extractor and the `.tscn`/`.tres`/`project.godot` scene +# extractor both source their grammars (`gdscript` and `godot_resource`, both +# from PrestonKnopp) from `tree-sitter-language-pack`, matching the `.gd`-only +# PR #1836. Both extractors degrade gracefully when the grammar is absent (the +# scene extractor falls back to a dependency-free line parser), so this stays +# optional like sql / pascal. +godot = ["tree-sitter-language-pack>=1.12.5,<1.13"] +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", "tree-sitter-language-pack>=1.12.5,<1.13"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/test_godot_scene.py b/tests/test_godot_scene.py index 24a5ce7ad..5144f9694 100644 --- a/tests/test_godot_scene.py +++ b/tests/test_godot_scene.py @@ -4,14 +4,25 @@ from pathlib import Path from graphify.extract import extract_godot_scene, _make_id, _file_stem +from graphify.extractors import godot_scene as gs def _edges(result, relation): return [e for e in result["edges"] if e["relation"] == relation] +def _norm(result): + """Order-independent snapshot of a result's nodes and edges.""" + n = sorted((x["id"], x["label"], x.get("source_location") or "") + for x in result["nodes"]) + e = sorted((x["source"], x["target"], x["relation"], + x.get("context") or "", x.get("source_location") or "") + for x in result["edges"]) + return n, e + + class TestGodotScene(unittest.TestCase): - """The .tscn/.tres/project.godot extractor needs no grammar.""" + """The .tscn/.tres/project.godot extractor (grammar path when available).""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -82,9 +93,85 @@ def test_project_godot_autoloads_and_main_scene(self): 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())) + gstate = _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) + self.assertIn(gstate, script_targets) + + def test_line_parser_fallback_matches(self): + # Force the dependency-free line parser (as if the grammar were absent) + # and confirm it still emits the same edges the default path produces. + scene = 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") + + [connection signal="body_entered" from="Enemy" to="." method="take_damage"] + """) + default = extract_godot_scene(scene) + saved = gs._RESOURCE_PARSER + try: + gs._RESOURCE_PARSER = None # disable grammar -> line parser + forced_lines = extract_godot_scene(scene) + finally: + gs._RESOURCE_PARSER = saved + # The line fallback must be at least as capable as the default path here. + self.assertEqual(_norm(default), _norm(forced_lines)) + + +@unittest.skipUnless(gs._load_resource_parser() is not None, + "godot_resource grammar (tree-sitter-language-pack) not installed") +class TestGodotSceneGrammar(unittest.TestCase): + """Behaviour specific to the grammar front end.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / "scripts").mkdir() + (self.root / "project.godot").write_text("config_version=5\n") + (self.root / "scripts" / "enemy.gd").write_text("extends Node\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_grammar_and_line_parsers_agree(self): + scene = self._write("Main.tscn", """ + [gd_scene load_steps=2 format=3 uid="uid://abc"] + + [ext_resource type="Script" path="res://scripts/enemy.gd" id="1_e"] + [ext_resource type="Texture2D" path="res://art/hero.png" id="2_t"] + + [node name="Enemy" type="CharacterBody2D"] + script = ExtResource("1_e") + """) + text = scene.read_text() + via_grammar = gs._build_scene(scene, gs._blocks_from_grammar(text)) + via_lines = gs._build_scene(scene, gs._blocks_from_lines(text)) + self.assertEqual(_norm(via_grammar), _norm(via_lines)) + + def test_grammar_survives_bracket_in_quoted_value(self): + # A ']' inside a quoted attribute value trips the line-regex section + # matcher but the grammar parses it correctly. + scene = self._write("Weird.tscn", """ + [gd_scene format=3] + + [ext_resource type="Script" path="res://scripts/enemy.gd" id="1_e"] + + [node name="Odd]Name" type="Node2D"] + script = ExtResource("1_e") + """) + r = extract_godot_scene(scene) + enemy_gd = _make_id(str((self.root / "scripts" / "enemy.gd").resolve())) + attaches = {e["target"] for e in _edges(r, "attaches_script")} + self.assertIn(enemy_gd, attaches) if __name__ == "__main__": From de0f6b214b0cc706c453306af128ee7d647d2448 Mon Sep 17 00:00:00 2001 From: ozdemirsarman Date: Mon, 27 Jul 2026 17:17:11 +0300 Subject: [PATCH 3/3] =?UTF-8?q?refactor(godot):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20rename=20to=20godot=5Fresource,=20wire=20analyze/bu?= =?UTF-8?q?ild/MIGRATION,=20README=20+=20fixtures=20(#1929)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register .gd (gdscript) and .tscn/.tres/project.godot (godot_resource) in analyze.py, build.py and extractors/MIGRATION.md, matching #1836. - Rename the scene extractor to godot_resource (module, extract_godot_resource, registry key, wiring) per review. - Shrink the pyproject godot comment to one line. - README: add a godot extras row and split the godot file types into their own GDScript / Godot resources rows. - Move the inline gdscript/godot test bodies into tests/fixtures/godot/ and rename the test file to test_godot_resource.py. --- README.md | 5 +- graphify/analyze.py | 2 + graphify/build.py | 2 + graphify/extract.py | 8 +- graphify/extractors/MIGRATION.md | 2 + graphify/extractors/__init__.py | 4 +- .../{godot_scene.py => godot_resource.py} | 4 +- pyproject.toml | 8 +- tests/fixtures/godot/gdscript/analytics.gd | 3 + tests/fixtures/godot/gdscript/audio.gd | 2 + tests/fixtures/godot/gdscript/empty.gd | 0 tests/fixtures/godot/gdscript/enemy.gd | 20 ++ tests/fixtures/godot/gdscript/player.gd | 3 + tests/fixtures/godot/gdscript/project.godot | 4 + tests/fixtures/godot/resource/project.godot | 7 + .../godot/resource/scenes/Bullet.tscn | 3 + .../fixtures/godot/resource/scenes/Main.tscn | 11 ++ .../fixtures/godot/resource/scripts/enemy.gd | 4 + .../godot/resource/scripts/game_state.gd | 1 + tests/fixtures/godot/resource/weird.tscn | 6 + tests/test_gdscript.py | 53 +----- tests/test_godot_resource.py | 109 +++++++++++ tests/test_godot_scene.py | 178 ------------------ 23 files changed, 202 insertions(+), 237 deletions(-) rename graphify/extractors/{godot_scene.py => godot_resource.py} (99%) create mode 100644 tests/fixtures/godot/gdscript/analytics.gd create mode 100644 tests/fixtures/godot/gdscript/audio.gd create mode 100644 tests/fixtures/godot/gdscript/empty.gd create mode 100644 tests/fixtures/godot/gdscript/enemy.gd create mode 100644 tests/fixtures/godot/gdscript/player.gd create mode 100644 tests/fixtures/godot/gdscript/project.godot create mode 100644 tests/fixtures/godot/resource/project.godot create mode 100644 tests/fixtures/godot/resource/scenes/Bullet.tscn create mode 100644 tests/fixtures/godot/resource/scenes/Main.tscn create mode 100644 tests/fixtures/godot/resource/scripts/enemy.gd create mode 100644 tests/fixtures/godot/resource/scripts/game_state.gd create mode 100644 tests/fixtures/godot/resource/weird.tscn create mode 100644 tests/test_godot_resource.py delete mode 100644 tests/test_godot_scene.py diff --git a/README.md b/README.md index 560dff05e..05cfd01b4 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi | `dm` | BYOND DreamMaker `.dm`/`.dme` AST extraction (may need a C compiler + `python3-dev` if no wheel matches your platform) | `uv tool install "graphifyy[dm]"` | | `terraform` | Terraform / HCL `.tf`/`.tfvars`/`.hcl` AST extraction | `uv tool install "graphifyy[terraform]"` | | `pascal` | Pascal / Delphi `.pas`/`.dpr`/`.dpk`/`.inc` AST extraction (more accurate `calls`/`inherits` edges; falls back to a regex extractor when absent) | `uv tool install "graphifyy[pascal]"` | +| `godot` | GDScript `.gd` + Godot `.tscn`/`.tres`/`project.godot` extraction (grammars via `tree-sitter-language-pack`; scene/resource extractor falls back to a dependency-free line parser) | `uv tool install "graphifyy[godot]"` | | `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` | | `all` | Everything above | `uv tool install "graphifyy[all]"` | @@ -327,7 +328,9 @@ 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 .gd .tscn .tres` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.gd` (GDScript) and `.tscn`/`.tres`/`project.godot` (Godot scenes, resources, autoloads, signal wires) use `uv tool install graphifyy[godot]` (grammars bundled via `tree-sitter-language-pack`); the scene/resource extractor still falls back to a dependency-free line parser when the extra is absent; `.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` (`.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) | +| GDScript | `.gd` (requires `uv tool install graphifyy[godot]`; classes, functions, signals, `preload`/`load` imports, and autoload-resolved calls; grammar via `tree-sitter-language-pack`) | +| Godot resources | `.tscn` `.tres` `project.godot` (requires `uv tool install graphifyy[godot]`; scene-tree nodes, attached scripts, sub-scene instances, autoloads, and signal wires; `godot_resource` grammar via `tree-sitter-language-pack`, falls back to a dependency-free line parser when absent) | | 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/analyze.py b/graphify/analyze.py index cb6c76484..554e56486 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -33,6 +33,8 @@ **{e: "dotnet" for e in (".cs",)}, **{e: "php" for e in (".php",)}, **{e: "r" for e in (".r",)}, + **{e: "gdscript" for e in (".gd",)}, + **{e: "godot_resource" for e in (".tscn", ".tres", ".godot")}, } diff --git a/graphify/build.py b/graphify/build.py index caeaefdf9..e657b5aee 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -50,6 +50,8 @@ ".cxx": "c", ".hh": "c", ".hxx": "c", ".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c", ".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", + ".gd": "gdscript", + ".tscn": "godot_resource", ".tres": "godot_resource", ".godot": "godot_resource", } diff --git a/graphify/extract.py b/graphify/extract.py index 94c5a6c60..2c770ff30 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -39,7 +39,7 @@ 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.godot_resource import extract_godot_resource # 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 @@ -3893,9 +3893,9 @@ def add_existing_edge(edge: dict) -> None: ".astro": extract_astro, ".dart": extract_dart, ".gd": extract_gdscript, - ".tscn": extract_godot_scene, - ".tres": extract_godot_scene, - ".godot": extract_godot_scene, + ".tscn": extract_godot_resource, + ".tres": extract_godot_resource, + ".godot": extract_godot_resource, ".v": extract_verilog, ".sv": extract_verilog, ".svh": extract_verilog, diff --git a/graphify/extractors/MIGRATION.md b/graphify/extractors/MIGRATION.md index 75e1525e2..9a92d86e5 100644 --- a/graphify/extractors/MIGRATION.md +++ b/graphify/extractors/MIGRATION.md @@ -25,6 +25,8 @@ written so an AI agent can execute it in a single session. | sln | yes | | pascal_forms (dfm + lfm) | yes | | json_config | yes | +| gdscript | yes (added directly as a per-language extractor) | +| godot_resource (tscn/tres/project.godot) | yes (added directly as a per-language extractor) | | (config-driven core: python, js, java, c, cpp, csharp, kotlin, scala, php, lua, swift, groovy, vue, svelte, astro, xaml, groovy) | no — shared _extract_generic core, move as one batch | | (other bespoke: julia, verilog, markdown, objc, csproj, slnx, lazarus_package, pascal) | no | diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index 773b75c0e..b784c814b 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -19,7 +19,7 @@ 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.godot_resource import extract_godot_resource from graphify.extractors.json_config import extract_json from graphify.extractors.julia import extract_julia from graphify.extractors.markdown import extract_markdown @@ -49,7 +49,7 @@ "fortran": extract_fortran, "gdscript": extract_gdscript, "go": extract_go, - "godot_scene": extract_godot_scene, + "godot_resource": extract_godot_resource, "json": extract_json, "julia": extract_julia, "lazarus_form": extract_lazarus_form, diff --git a/graphify/extractors/godot_scene.py b/graphify/extractors/godot_resource.py similarity index 99% rename from graphify/extractors/godot_scene.py rename to graphify/extractors/godot_resource.py index 319780d19..103ab3d55 100644 --- a/graphify/extractors/godot_scene.py +++ b/graphify/extractors/godot_resource.py @@ -1,4 +1,4 @@ -"""Godot scene / resource / project extractor. +"""Godot resource extractor (.tscn scenes, .tres resources, project.godot). Parses Godot's text formats for ``.tscn`` (PackedScene), ``.tres`` (Resource) and ``project.godot`` and emits Graphify's node/edge dicts: @@ -222,7 +222,7 @@ def _blocks(text: str) -> list[_Block]: # Shared edge builders # --------------------------------------------------------------------------- -def extract_godot_scene(path: Path) -> dict: +def extract_godot_resource(path: Path) -> dict: try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: diff --git a/pyproject.toml b/pyproject.toml index 76e5509cd..999fe88ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,13 +80,7 @@ pascal = ["tree-sitter-pascal"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] -# Godot (Engine) extraction. The standalone `tree-sitter-gdscript` wheel is not -# on PyPI, so the `.gd` extractor and the `.tscn`/`.tres`/`project.godot` scene -# extractor both source their grammars (`gdscript` and `godot_resource`, both -# from PrestonKnopp) from `tree-sitter-language-pack`, matching the `.gd`-only -# PR #1836. Both extractors degrade gracefully when the grammar is absent (the -# scene extractor falls back to a dependency-free line parser), so this stays -# optional like sql / pascal. +# Godot: `gdscript` + `godot_resource` grammars via tree-sitter-language-pack (optional; degrades gracefully). godot = ["tree-sitter-language-pack>=1.12.5,<1.13"] 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", "tree-sitter-language-pack>=1.12.5,<1.13"] diff --git a/tests/fixtures/godot/gdscript/analytics.gd b/tests/fixtures/godot/gdscript/analytics.gd new file mode 100644 index 000000000..3b2ac6574 --- /dev/null +++ b/tests/fixtures/godot/gdscript/analytics.gd @@ -0,0 +1,3 @@ +extends Node +func track(name): + pass diff --git a/tests/fixtures/godot/gdscript/audio.gd b/tests/fixtures/godot/gdscript/audio.gd new file mode 100644 index 000000000..c03b4cd89 --- /dev/null +++ b/tests/fixtures/godot/gdscript/audio.gd @@ -0,0 +1,2 @@ +extends Node +class_name AudioManager diff --git a/tests/fixtures/godot/gdscript/empty.gd b/tests/fixtures/godot/gdscript/empty.gd new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/godot/gdscript/enemy.gd b/tests/fixtures/godot/gdscript/enemy.gd new file mode 100644 index 000000000..4e744c395 --- /dev/null +++ b/tests/fixtures/godot/gdscript/enemy.gd @@ -0,0 +1,20 @@ +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) diff --git a/tests/fixtures/godot/gdscript/player.gd b/tests/fixtures/godot/gdscript/player.gd new file mode 100644 index 000000000..ad9c2f6f0 --- /dev/null +++ b/tests/fixtures/godot/gdscript/player.gd @@ -0,0 +1,3 @@ +extends Node +func attack(): + Analytics.track("hit") diff --git a/tests/fixtures/godot/gdscript/project.godot b/tests/fixtures/godot/gdscript/project.godot new file mode 100644 index 000000000..2d0585360 --- /dev/null +++ b/tests/fixtures/godot/gdscript/project.godot @@ -0,0 +1,4 @@ +config_version=5 + +[autoload] +Analytics="*res://analytics.gd" diff --git a/tests/fixtures/godot/resource/project.godot b/tests/fixtures/godot/resource/project.godot new file mode 100644 index 000000000..d938daec9 --- /dev/null +++ b/tests/fixtures/godot/resource/project.godot @@ -0,0 +1,7 @@ +config_version=5 + +[application] +run/main_scene="res://scenes/Main.tscn" + +[autoload] +GameState="*res://scripts/game_state.gd" diff --git a/tests/fixtures/godot/resource/scenes/Bullet.tscn b/tests/fixtures/godot/resource/scenes/Bullet.tscn new file mode 100644 index 000000000..76124129d --- /dev/null +++ b/tests/fixtures/godot/resource/scenes/Bullet.tscn @@ -0,0 +1,3 @@ +[gd_scene format=3 uid="uid://bul"] + +[node name="Bullet" type="Area2D"] diff --git a/tests/fixtures/godot/resource/scenes/Main.tscn b/tests/fixtures/godot/resource/scenes/Main.tscn new file mode 100644 index 000000000..e72a721a0 --- /dev/null +++ b/tests/fixtures/godot/resource/scenes/Main.tscn @@ -0,0 +1,11 @@ +[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"] diff --git a/tests/fixtures/godot/resource/scripts/enemy.gd b/tests/fixtures/godot/resource/scripts/enemy.gd new file mode 100644 index 000000000..853108a37 --- /dev/null +++ b/tests/fixtures/godot/resource/scripts/enemy.gd @@ -0,0 +1,4 @@ +class_name Enemy +extends CharacterBody2D +func take_damage(a): + pass diff --git a/tests/fixtures/godot/resource/scripts/game_state.gd b/tests/fixtures/godot/resource/scripts/game_state.gd new file mode 100644 index 000000000..61510e14c --- /dev/null +++ b/tests/fixtures/godot/resource/scripts/game_state.gd @@ -0,0 +1 @@ +extends Node diff --git a/tests/fixtures/godot/resource/weird.tscn b/tests/fixtures/godot/resource/weird.tscn new file mode 100644 index 000000000..7ff5e278b --- /dev/null +++ b/tests/fixtures/godot/resource/weird.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scripts/enemy.gd" id="1_e"] + +[node name="Odd]Name" type="Node2D"] +script = ExtResource("1_e") diff --git a/tests/test_gdscript.py b/tests/test_gdscript.py index 261b2a778..18f1ad769 100644 --- a/tests/test_gdscript.py +++ b/tests/test_gdscript.py @@ -1,5 +1,5 @@ +import shutil import tempfile -import textwrap import unittest from pathlib import Path @@ -7,6 +7,7 @@ from graphify.extractors.gdscript import _load_gdscript_parser _HAS_GRAMMAR = _load_gdscript_parser() is not None +_FIXTURES = Path(__file__).parent / "fixtures" / "godot" / "gdscript" def _rels(result): @@ -20,42 +21,17 @@ def _edge(result, relation): @unittest.skipUnless(_HAS_GRAMMAR, "tree-sitter-gdscript grammar not installed") class TestGDScript(unittest.TestCase): def setUp(self): + # Copy the fixture project into a temp dir so res:// resolution and the + # per-project autoload cache anchor on a real, isolated project root. self.tmp = tempfile.TemporaryDirectory() - self.root = Path(self.tmp.name) - (self.root / "project.godot").write_text("config_version=5\n") + self.root = Path(self.tmp.name) / "proj" + shutil.copytree(_FIXTURES, self.root) 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) - """) + p = self.root / "enemy.gd" r = extract_gdscript(p) rels = _rels(r) for expected in ("defines", "extends", "declares", "emits", "connects", "calls", "imports"): @@ -88,19 +64,10 @@ def test_class_extends_functions_signals_calls(self): 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") - """) + caller = self.root / "player.gd" 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") + want = _make_id(_file_stem(self.root / "analytics.gd"), "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) @@ -109,7 +76,7 @@ def test_autoload_method_call_resolves_to_script_function(self): 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", "") + p = self.root / "empty.gd" r = extract_gdscript(p) self.assertTrue(any(n["label"] == "empty.gd" for n in r["nodes"])) self.assertNotIn("error", r) diff --git a/tests/test_godot_resource.py b/tests/test_godot_resource.py new file mode 100644 index 000000000..f014b53e6 --- /dev/null +++ b/tests/test_godot_resource.py @@ -0,0 +1,109 @@ +import shutil +import tempfile +import unittest +from pathlib import Path + +from graphify.extract import extract_godot_resource, _make_id, _file_stem +from graphify.extractors import godot_resource as gr + +_FIXTURES = Path(__file__).parent / "fixtures" / "godot" / "resource" + + +def _edges(result, relation): + return [e for e in result["edges"] if e["relation"] == relation] + + +def _norm(result): + """Order-independent snapshot of a result's nodes and edges.""" + n = sorted((x["id"], x["label"], x.get("source_location") or "") + for x in result["nodes"]) + e = sorted((x["source"], x["target"], x["relation"], + x.get("context") or "", x.get("source_location") or "") + for x in result["edges"]) + return n, e + + +class _FixtureProject(unittest.TestCase): + """Copy the fixture Godot project into an isolated temp dir per test.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "proj" + shutil.copytree(_FIXTURES, self.root) + + def tearDown(self): + self.tmp.cleanup() + + +class TestGodotResource(_FixtureProject): + """The .tscn/.tres/project.godot extractor (grammar path when available).""" + + def test_scene_script_subscene_and_connection(self): + p = self.root / "scenes" / "Main.tscn" + r = extract_godot_resource(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.root / "project.godot" + r = extract_godot_resource(p) + + self.assertTrue(_edges(r, "autoload"), "no autoload edge emitted") + self.assertTrue(_edges(r, "main_scene"), "no main_scene edge emitted") + + gstate = _make_id(str((self.root / "scripts" / "game_state.gd").resolve())) + script_targets = {e["target"] for e in _edges(r, "script")} + self.assertIn(gstate, script_targets) + + def test_line_parser_fallback_matches(self): + # Force the dependency-free line parser (as if the grammar were absent) + # and confirm it still emits the same edges the default path produces. + scene = self.root / "scenes" / "Main.tscn" + default = extract_godot_resource(scene) + saved = gr._RESOURCE_PARSER + try: + gr._RESOURCE_PARSER = None # disable grammar -> line parser + forced_lines = extract_godot_resource(scene) + finally: + gr._RESOURCE_PARSER = saved + self.assertEqual(_norm(default), _norm(forced_lines)) + + +@unittest.skipUnless(gr._load_resource_parser() is not None, + "godot_resource grammar (tree-sitter-language-pack) not installed") +class TestGodotResourceGrammar(_FixtureProject): + """Behaviour specific to the grammar front end.""" + + def test_grammar_and_line_parsers_agree(self): + scene = self.root / "scenes" / "Main.tscn" + text = scene.read_text() + via_grammar = gr._build_scene(scene, gr._blocks_from_grammar(text)) + via_lines = gr._build_scene(scene, gr._blocks_from_lines(text)) + self.assertEqual(_norm(via_grammar), _norm(via_lines)) + + def test_grammar_survives_bracket_in_quoted_value(self): + # A ']' inside a quoted attribute value (node name "Odd]Name") trips the + # line-regex section matcher but the grammar parses it correctly. + scene = self.root / "weird.tscn" + r = extract_godot_resource(scene) + enemy_gd = _make_id(str((self.root / "scripts" / "enemy.gd").resolve())) + attaches = {e["target"] for e in _edges(r, "attaches_script")} + self.assertIn(enemy_gd, attaches) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_godot_scene.py b/tests/test_godot_scene.py deleted file mode 100644 index 5144f9694..000000000 --- a/tests/test_godot_scene.py +++ /dev/null @@ -1,178 +0,0 @@ -import tempfile -import textwrap -import unittest -from pathlib import Path - -from graphify.extract import extract_godot_scene, _make_id, _file_stem -from graphify.extractors import godot_scene as gs - - -def _edges(result, relation): - return [e for e in result["edges"] if e["relation"] == relation] - - -def _norm(result): - """Order-independent snapshot of a result's nodes and edges.""" - n = sorted((x["id"], x["label"], x.get("source_location") or "") - for x in result["nodes"]) - e = sorted((x["source"], x["target"], x["relation"], - x.get("context") or "", x.get("source_location") or "") - for x in result["edges"]) - return n, e - - -class TestGodotScene(unittest.TestCase): - """The .tscn/.tres/project.godot extractor (grammar path when available).""" - - 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") - - gstate = _make_id(str((self.root / "scripts" / "game_state.gd").resolve())) - script_targets = {e["target"] for e in _edges(r, "script")} - self.assertIn(gstate, script_targets) - - def test_line_parser_fallback_matches(self): - # Force the dependency-free line parser (as if the grammar were absent) - # and confirm it still emits the same edges the default path produces. - scene = 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") - - [connection signal="body_entered" from="Enemy" to="." method="take_damage"] - """) - default = extract_godot_scene(scene) - saved = gs._RESOURCE_PARSER - try: - gs._RESOURCE_PARSER = None # disable grammar -> line parser - forced_lines = extract_godot_scene(scene) - finally: - gs._RESOURCE_PARSER = saved - # The line fallback must be at least as capable as the default path here. - self.assertEqual(_norm(default), _norm(forced_lines)) - - -@unittest.skipUnless(gs._load_resource_parser() is not None, - "godot_resource grammar (tree-sitter-language-pack) not installed") -class TestGodotSceneGrammar(unittest.TestCase): - """Behaviour specific to the grammar front end.""" - - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - self.root = Path(self.tmp.name) - (self.root / "scripts").mkdir() - (self.root / "project.godot").write_text("config_version=5\n") - (self.root / "scripts" / "enemy.gd").write_text("extends Node\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_grammar_and_line_parsers_agree(self): - scene = self._write("Main.tscn", """ - [gd_scene load_steps=2 format=3 uid="uid://abc"] - - [ext_resource type="Script" path="res://scripts/enemy.gd" id="1_e"] - [ext_resource type="Texture2D" path="res://art/hero.png" id="2_t"] - - [node name="Enemy" type="CharacterBody2D"] - script = ExtResource("1_e") - """) - text = scene.read_text() - via_grammar = gs._build_scene(scene, gs._blocks_from_grammar(text)) - via_lines = gs._build_scene(scene, gs._blocks_from_lines(text)) - self.assertEqual(_norm(via_grammar), _norm(via_lines)) - - def test_grammar_survives_bracket_in_quoted_value(self): - # A ']' inside a quoted attribute value trips the line-regex section - # matcher but the grammar parses it correctly. - scene = self._write("Weird.tscn", """ - [gd_scene format=3] - - [ext_resource type="Script" path="res://scripts/enemy.gd" id="1_e"] - - [node name="Odd]Name" type="Node2D"] - script = ExtResource("1_e") - """) - r = extract_godot_scene(scene) - enemy_gd = _make_id(str((self.root / "scripts" / "enemy.gd").resolve())) - attaches = {e["target"] for e in _edges(r, "attaches_script")} - self.assertIn(enemy_gd, attaches) - - -if __name__ == "__main__": - unittest.main()