From 44e3cf71ab80366c8d4c3e890e7e2f20c35fe5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gaudard?= Date: Mon, 12 Oct 2026 14:56:34 +0200 Subject: [PATCH] feat(extractor): add native GDScript (Godot 4) support Map .gd -> gdscript. Extract symbols (class_name, inner classes, func/static, _init ctor, signals with arity, const, var/@export properties, enums) with function-local var/const excluded. Capture references (extends inheritance, type/return-type, primitives filtered), calls (call + attribute_call), and imports (preload/load res:// -> import edges). All logic is lang == "gdscript"-gated; no regression for existing languages. Add tests + CHANGELOG. --- CHANGELOG.md | 26 +++ src/code_memory/extractor/treesitter.py | 137 ++++++++++++- tests/test_extractor_gdscript.py | 244 ++++++++++++++++++++++++ 3 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 tests/test_extractor_gdscript.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c56fcd1..cc0b172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,32 @@ explains intent. ## [Unreleased] +### Added + +**GDScript (Godot 4) extraction.** ``.gd`` files now map to the +``gdscript`` grammar (``lang_for("x.gd") == "gdscript"``). The extractor +captures ``class_name`` and inner ``class`` declarations, ``func`` +(including ``static func``) with parameter arity, ``_init`` constructors, +``signal`` (with arity), file- and class-scope ``const`` / ``var`` +(including ``@export var``) properties, and ``enum`` — function-local +``var`` / ``const`` are excluded, matching the other languages. +``extends`` inheritance and parameter / return / var / const type +annotations become references; ``call`` and ``attribute_call`` +(``obj.method()``) become call edges with call-site arity; and +``preload("res://...")`` / ``load("res://...")`` string arguments become +import edges (the call itself is suppressed — it is a file dependency, not +a callable). + +v1 limitations: Godot engine types (``Vector2``, ``Color``, ``Signal``, +``Dictionary``, ``NodePath``, ``Array``, ...) are emitted as references — +only scalar primitives are filtered. ``preload`` / ``load`` match by bare +callee text, so a user-defined ``func load(...)`` shadowing the builtin is +still treated as an import; real shadowing resolution is deferred. + +**Reason:** Godot 4 is a supported target (client plus headless +dedicated-server replays share GDScript simulation code); without native +grammar support a ``.gd`` codebase extracts to an empty graph. + ## [0.11.0] — 2026-07-30 Release theme: **Clean-architecture guards, cross-context symbol resolution, embed-cache durability**. diff --git a/src/code_memory/extractor/treesitter.py b/src/code_memory/extractor/treesitter.py index 1446544..8039b9a 100644 --- a/src/code_memory/extractor/treesitter.py +++ b/src/code_memory/extractor/treesitter.py @@ -31,6 +31,8 @@ ".phtml": "php", # Dart / Flutter ".dart": "dart", + # Godot 4 — tree_sitter_language_pack ships the ``gdscript`` grammar. + ".gd": "gdscript", } SYMBOL_NODE_TYPES = { @@ -90,6 +92,48 @@ "setter_signature", } +# GDScript-specific symbol nodes. ``class_definition`` and +# ``function_definition`` are already in ``SYMBOL_NODE_TYPES`` (shared with +# Dart / Python); the names below are unique to the Godot grammar but are +# still gated on ``lang == "gdscript"`` so other languages' output stays +# byte-for-byte stable. +_GDSCRIPT_SYMBOL_NODE_TYPES = frozenset( + { + "class_name_statement", + "constructor_definition", + "signal_statement", + "const_statement", + "variable_statement", + "enum_definition", + } +) + +# GDScript declarations that are symbols only at file / class scope. Inside a +# ``func`` body a ``var`` / ``const`` is a local, not a property; emitting it +# would flood symbol queries the way minified bundles would. +_GDSCRIPT_LOCAL_CAPABLE_NODE_TYPES = frozenset({"variable_statement", "const_statement"}) + +# Ancestor types that mark a GDScript function-body scope. Any ``var`` / +# ``const`` nested under one of these is a local. ``body`` covers ``func`` and +# ``_init``; ``get_body`` / ``set_body`` cover property accessors. +_GDSCRIPT_FUNCTION_SCOPE_NODE_TYPES = frozenset( + {"function_definition", "constructor_definition", "body", "get_body", "set_body"} +) + +# GDScript nodes that expose a type annotation via a ``type`` field. +# ``function_definition`` / ``constructor_definition`` instead use +# ``return_type`` (handled explicitly in the walker). ``signal`` has no type +# field: its parameter types flow through ``typed_parameter``. +_GDSCRIPT_TYPE_FIELD_NODES = frozenset( + { + "typed_parameter", + "variable_statement", + "const_statement", + "function_definition", + "constructor_definition", + } +) + CALL_NODE_TYPES = { "call_expression", "call", @@ -434,6 +478,10 @@ def extract_file(path: str | Path) -> ExtractedFile | None: "getter_signature", "setter_signature", "constructor_signature", + # GDScript — ``func`` reuses ``function_definition`` (already listed); + # these two are the extra callables that carry a parameter list. + "constructor_definition", + "signal_statement", } @@ -541,11 +589,17 @@ def _walk( if any(c.type == "compound_statement" for c in node.children): pushed_ns = True - if t in SYMBOL_NODE_TYPES or ( - ex.lang == "dart" and t in _DART_SIGNATURE_SYMBOL_NODES + if ( + t in SYMBOL_NODE_TYPES + or (ex.lang == "dart" and t in _DART_SIGNATURE_SYMBOL_NODES) + or (ex.lang == "gdscript" and t in _GDSCRIPT_SYMBOL_NODE_TYPES) ): name = _symbol_name(node, source) - if name: + if name and not ( + ex.lang == "gdscript" + and t in _GDSCRIPT_LOCAL_CAPABLE_NODE_TYPES + and _gdscript_is_function_local(node) + ): partial = ( t in _PARTIAL_CAPABLE_KINDS and _has_partial_modifier(node, source) ) @@ -578,12 +632,17 @@ def _walk( injected = _inject_type(node, source) if injected: ex.injects.append(injected) - elif t in CALL_NODE_TYPES: + elif t in CALL_NODE_TYPES or (ex.lang == "gdscript" and t == "attribute_call"): + # GDScript ``obj.method(...)`` parses as ``attribute_call``; the + # grammar has no single call-expression node for method invocation. + gd_import = _gdscript_import_target(node, source) if ex.lang == "gdscript" else None + if gd_import: + # ``preload`` / ``load`` are file dependencies, not callables. + ex.imports.append(gd_import) # Angular DI: ``inject(Token)`` becomes an INJECTS edge instead # of a (stoplisted) CALL. Without this, the entire DI graph for # Angular 14+ codebases is invisible. - token = _angular_inject_token(node, source) - if token: + elif (token := _angular_inject_token(node, source)): ex.injects.append(token) else: callee = _callee_name(node, source) @@ -639,6 +698,21 @@ def _walk( for child in node.children: if child.type in _DART_TYPE_CHILD_NODES: _collect_type_refs(child, source, ex.references) + if ex.lang == "gdscript": + # Inheritance: ``extends Node2D`` (the whole subtree is a type). + # Type annotations: parameter / var / const ``type`` fields and + # function / constructor ``return_type`` (signal parameter types + # arrive via ``typed_parameter``). + # NOTE: Godot *engine* types (Vector2, Color, Signal, Dictionary, + # NodePath, Array, ...) are intentionally emitted as references in + # v1 — only scalar primitives are filtered; they resolve like user + # types. + if t == "extends_statement": + _collect_type_refs(node, source, ex.references) + elif t in _GDSCRIPT_TYPE_FIELD_NODES: + tnode = node.child_by_field_name("type") or node.child_by_field_name("return_type") + if tnode is not None: + _collect_type_refs(tnode, source, ex.references) # C# pattern / cast / typeof: tree-sitter doesn't expose a `type` # field on these, so collect the type child positionally. if t == "cast_expression": @@ -741,6 +815,10 @@ def _symbol_name(node: Node, source: bytes) -> str | None: if child.type == "identifier": return _slice(source, child) return None + if node.type == "constructor_definition": + # GDScript ``func _init()``: the grammar exposes no ``name`` field; + # the constructor is identified by the ``_init`` token. + return "_init" for child in node.children: if child.type in {"identifier", "type_identifier", "property_identifier"}: return _slice(source, child) @@ -1525,6 +1603,53 @@ def _dart_import_uri(node: Node, source: bytes) -> str | None: return None +def _gdscript_import_target(node: Node, source: bytes) -> str | None: + """Return the ``res://`` path of a GDScript ``preload`` / ``load`` call. + + The grammar parses both as a plain ``call`` whose first child is a bare + ``identifier``; only a string-literal first argument is accepted. Returns + ``None`` for ordinary calls so the CALLS edge path still runs. + + Caveat (accepted v1 limitation): the callee is matched by bare text only, + so a user-defined ``func load(p)`` shadowing the engine builtin still + produces an import edge and suppresses the call. This mirrors the + text-based callee recovery used by the other languages; a real fix needs + scope resolution, out of scope here. + """ + if node.type != "call": + return None + first = node.children[0] if node.children else None + if first is None or first.type != "identifier": + return None + if _slice(source, first) not in {"preload", "load"}: + return None + args = node.child_by_field_name("arguments") + if args is None: + return None + for sub in args.children: + if sub.type == "string": + text = _slice(source, sub).strip().strip("'\"") + return text or None + return None + + +def _gdscript_is_function_local(node: Node) -> bool: + """True when ``node`` sits inside a GDScript function-like body. + + A ``var`` / ``const`` is a property at file or class scope but only a + local inside a ``func`` (``body``) or a property accessor + (``get_body`` / ``set_body``). Walks the ``parent`` chain — the grammar + always attaches parents, so this is cheaper and clearer than threading an + extra scope stack through ``_walk``. + """ + parent = node.parent + while parent is not None: + if parent.type in _GDSCRIPT_FUNCTION_SCOPE_NODE_TYPES: + return True + parent = parent.parent + return False + + def _dart_callee(node: Node, source: bytes) -> str | None: """Resolve the callee of a Dart ``argument_part`` call site. diff --git a/tests/test_extractor_gdscript.py b/tests/test_extractor_gdscript.py new file mode 100644 index 0000000..6f89a48 --- /dev/null +++ b/tests/test_extractor_gdscript.py @@ -0,0 +1,244 @@ +"""Godot 4 GDScript coverage for the tree-sitter extractor. + +GDScript's grammar is close to Python but adds Godot-specific declarations +that the generic walker does not know about, so these tests pin the four +primitives the rest of the pipeline depends on: + +- symbols: top-level ``class_name``, inner ``class`` definitions, + ``func`` (incl. ``static func``) + parameter arity, ``_init`` + constructors (the grammar exposes *no* ``name`` field on + ``constructor_definition`` — the ``_init`` token is the node's literal + text), ``signal`` (arity), ``const``, ``var`` / ``@export var`` + properties, and ``enum`` declarations. +- imports: ``preload("res://...")`` / ``load("res://...")`` are parsed as + plain ``call`` nodes; their string argument is surfaced as an import + edge and the call itself is suppressed (it is a file dependency, not a + callable). +- calls: ``call`` (``foo(args)``) and ``attribute_call`` + (``obj.method(args)``), each with call-site arity. +- references: ``extends`` inheritance (``extends Node2D`` / + ``class_name Foo extends RefCounted``) plus parameter / return / const / + variable type annotations — minus Godot's primitive scalars. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from code_memory.extractor.treesitter import Extractor, extract_file, lang_for + +GDSCRIPT_SRC = """\ +extends Node2D + +class_name Foo + +signal hit(amount: int) +signal ready_ref(target: Target) + +enum State { IDLE, RUN } + +const MAX_HP := 100 +const DEFAULT_COLOR: Color = Color.WHITE + +@export var speed: float = 10.0 +var untyped = 3 +var health: Health +var repo: Repo + +class Inner: + var value: int = 1 + + func compute(a: int, b: int) -> Result: + return make(a, b) + +static func bar(x: int) -> int: + return x + +func _init() -> void: + pass + +func helper(who: Who) -> Signal: + bar(1) + GameState.reset() + load("res://scenes/main.tscn") + +const Pre = preload("res://foo.gd") +""" + + +def _extract(tmp_path: Path, name: str, body: str): + f = tmp_path / name + f.write_text(textwrap.dedent(body), encoding="utf-8") + ex = extract_file(f) + assert ex is not None + return ex + + +def test_extension_maps_to_gdscript() -> None: + assert lang_for("Main.gd") == "gdscript" + assert lang_for("scenes/player.GD") == "gdscript" + assert lang_for("project.godot") is None + + +def test_extracts_symbols_with_arity(tmp_path: Path) -> None: + ex = _extract(tmp_path, "main.gd", GDSCRIPT_SRC) + assert ex.lang == "gdscript" + by_name = {s.name: s for s in ex.symbols} + + # top-level + inner class declarations + assert "Foo" in by_name + assert "Inner" in by_name + + # signals / constants / variables + assert "hit" in by_name + assert "ready_ref" in by_name + assert "MAX_HP" in by_name + assert "DEFAULT_COLOR" in by_name + assert "speed" in by_name + assert "untyped" in by_name + assert "health" in by_name + assert "repo" in by_name + + # functions / static functions / constructor + assert {"bar", "helper", "compute", "_init"} <= set(by_name) + + # signal parameter arity + assert by_name["hit"].param_count == 1 + assert by_name["ready_ref"].param_count == 1 + + # function parameter arity + assert by_name["bar"].param_count == 1 + assert by_name["compute"].param_count == 2 + assert by_name["helper"].param_count == 1 + + # the constructor carries the implicit ``_init`` name and zero params + ctor = by_name["_init"] + assert ctor.kind == "constructor_definition" + assert ctor.param_count == 0 + + # enum surfaces under its declared name + assert by_name["State"].kind == "enum_definition" + + +def test_extracts_preload_and_load_as_imports(tmp_path: Path) -> None: + ex = _extract(tmp_path, "main.gd", GDSCRIPT_SRC) + assert "res://foo.gd" in ex.imports + assert "res://scenes/main.tscn" in ex.imports + + +def test_preload_load_are_not_recorded_as_calls(tmp_path: Path) -> None: + ex = _extract(tmp_path, "main.gd", GDSCRIPT_SRC) + calls = {c.name for c in ex.calls} + assert "preload" not in calls + assert "load" not in calls + + +def test_extracts_calls_with_arity(tmp_path: Path) -> None: + ex = _extract(tmp_path, "main.gd", GDSCRIPT_SRC) + calls = {c.name for c in ex.calls} + # plain call + attribute (method) call + assert {"bar", "make", "reset"} <= calls + by_call = {c.name: c.arity for c in ex.calls} + assert by_call["bar"] == 1 + assert by_call["make"] == 2 + assert by_call["reset"] == 0 + + +def test_extracts_type_references(tmp_path: Path) -> None: + ex = _extract(tmp_path, "main.gd", GDSCRIPT_SRC) + refs = set(ex.references) + # inheritance + assert "Node2D" in refs + # signal parameter / return / const / variable annotations + assert {"Target", "Color", "Health", "Repo", "Result", "Who", "Signal"} <= refs + # Godot primitive scalars never surface as references + assert "int" not in refs + assert "float" not in refs + assert "void" not in refs + + +def test_inheritance_via_class_name_extends(tmp_path: Path) -> None: + src = "class_name Foo extends RefCounted\n" + ex = _extract(tmp_path, "foo.gd", src) + assert "RefCounted" in set(ex.references) + + +def test_extractor_walk_yields_gdscript_file(tmp_path: Path) -> None: + (tmp_path / "player.gd").write_text(GDSCRIPT_SRC, encoding="utf-8") + (tmp_path / "notes.txt").write_text("not code", encoding="utf-8") + # ``ignore_dirs=()`` avoids the pre-existing ``tmp`` entry in + # ``DEFAULT_IGNORE_DIRS`` which matches every pytest temp path under + # ``/tmp`` (unrelated to GDScript; tracked separately). + results = list(Extractor(ignore_dirs=(), respect_gitignore=False).walk(tmp_path)) + langs = {ex.lang for ex in results} + assert "gdscript" in langs + paths = {Path(ex.path).name for ex in results} + assert "player.gd" in paths + assert "notes.txt" not in paths + + +SCOPE_SRC = """\ +signal ping + +const TOP_CONST: int = 1 +var top_var: int = 2 + +@export var exported: int = 3 + +class Inner: + const CLASS_CONST := 4 + var class_var: int = 5 + + func method() -> void: + var local: int = 6 + const LOCAL_CONST := 7 + class_var = local + LOCAL_CONST + + +func free_func() -> void: + var free_local: int = 8 + const FREE_CONST := 9 +""" + + +def test_function_locals_are_not_symbols(tmp_path: Path) -> None: + ex = _extract(tmp_path, "scope.gd", SCOPE_SRC) + names = {s.name for s in ex.symbols} + + # file-scope + class-scope properties / constants ARE symbols + assert {"TOP_CONST", "top_var", "exported", "CLASS_CONST", "class_var"} <= names + # the class method and the free function are symbols + assert {"method", "free_func", "ping"} <= names + + # function-local ``var`` / ``const`` must NOT leak in as symbols + assert "local" not in names + assert "LOCAL_CONST" not in names + assert "free_local" not in names + assert "FREE_CONST" not in names + + +def test_engine_builtin_types_are_emitted_as_references(tmp_path: Path) -> None: + """v1 limitation: only scalar primitives are filtered. + + Godot engine types (Vector2, Color, Signal, ...) are deliberately + emitted as references so ``who references X`` still finds them. + """ + src = "var pos: Vector2\nvar tint: Color\nvar bus: Signal\n" + ex = _extract(tmp_path, "engine.gd", src) + refs = set(ex.references) + assert {"Vector2", "Color", "Signal"} <= refs + + +def test_load_callee_shadowing_is_documented_limitation(tmp_path: Path) -> None: + """v1 caveat: ``preload`` / ``load`` are matched by bare text. + + A user-defined ``func load(p)`` is indistinguishable from the builtin at + extract time, so the call is suppressed and an import edge is emitted. + This pins the accepted behaviour so a future scope-aware fix flips it + deliberately. + """ + src = 'func load(p: String) -> String:\n return p\n\n\nfunc f() -> void:\n load("x")\n' + ex = _extract(tmp_path, "shadow.gd", src) + assert "x" in ex.imports + assert "load" not in {c.name for c in ex.calls}