#!/usr/bin/env python3 """Discover and document the relationship between GDScript and Rust files. Outputs a Markdown report mapping: 1. Rust → Godot bindings exposed by `src/simulator/api-gdext/` (`#[derive(GodotClass)]` structs, with optional `#[class(rename=...)]`). 2. GDScript callers of each exposed class (grep over `*.gd`). 3. GDScript `class_name` declarations and `extends`/preload references to Rust-backed classes. 4. Crate-level dependency graph for `src/simulator/crates/*` and the two bridge crates (`api-gdext`, `api-wasm`). Re-runnable: `python3 tools/gd-rust-relationships.py [--out PATH]`. """ from __future__ import annotations import argparse import re import sys from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SIM_ROOT = REPO_ROOT / "src" / "simulator" GDEXT_SRC = SIM_ROOT / "api-gdext" / "src" CRATES_DIR = SIM_ROOT / "crates" GD_ROOTS = [REPO_ROOT / "src" / "game"] # all .gd files live here GODOT_CLASS_RE = re.compile(r"#\[derive\([^)]*\bGodotClass\b[^)]*\)\]") CLASS_ATTR_RE = re.compile(r"#\[class\(([^)]*)\)\]") RENAME_RE = re.compile(r"rename\s*=\s*([A-Za-z_][A-Za-z0-9_]*)") BASE_RE = re.compile(r"base\s*=\s*([A-Za-z_][A-Za-z0-9_]*)") STRUCT_RE = re.compile(r"^\s*pub\s+struct\s+([A-Za-z_][A-Za-z0-9_]*)") FUNC_RE = re.compile(r"#\[func\]") FN_NAME_RE = re.compile(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)") DEP_TABLE_RE = re.compile(r"^\[(dependencies|dev-dependencies|build-dependencies)\]") DEP_LINE_RE = re.compile(r"^([A-Za-z0-9_\-]+)\s*=") @dataclass class RustClass: name: str # exported name (after rename) — what Godot/GDScript sees rust_struct: str # original Rust struct identifier base: str | None file: Path line: int func_count: int = 0 callers: list[tuple[Path, int]] = field(default_factory=list) def find_godot_classes(gdext_src: Path) -> list[RustClass]: classes: list[RustClass] = [] for rs in sorted(gdext_src.rglob("*.rs")): text = rs.read_text(encoding="utf-8", errors="replace") lines = text.splitlines() i = 0 while i < len(lines): if GODOT_CLASS_RE.search(lines[i]): base: str | None = None rename: str | None = None # Look ahead for #[class(...)] and pub struct j = i + 1 while j < len(lines) and j < i + 12: m_class = CLASS_ATTR_RE.search(lines[j]) if m_class: attrs = m_class.group(1) if (mb := BASE_RE.search(attrs)): base = mb.group(1) if (mr := RENAME_RE.search(attrs)): rename = mr.group(1) m_struct = STRUCT_RE.match(lines[j]) if m_struct: struct_name = m_struct.group(1) # Count #[func]s in the impl block by scanning forward func_count = 0 k = j + 1 depth = 0 seen_impl = False while k < len(lines): ln = lines[k] if f"impl {struct_name}" in ln or f"impl I" in ln and struct_name in ln: seen_impl = True if FUNC_RE.search(ln) and ( f"impl {struct_name}" in "\n".join(lines[max(0, k - 30):k]) ): func_count += 1 k += 1 if k - j > 4000: break classes.append( RustClass( name=rename or struct_name, rust_struct=struct_name, base=base, file=rs, line=j + 1, func_count=func_count, ) ) i = j break j += 1 i += 1 return classes def find_gd_files(roots: list[Path]) -> list[Path]: files: list[Path] = [] for root in roots: if root.exists(): files.extend(sorted(root.rglob("*.gd"))) return files def map_callers(classes: list[RustClass], gd_files: list[Path]) -> None: # Build name → class index by_name: dict[str, list[RustClass]] = defaultdict(list) for c in classes: by_name[c.name].append(c) # Word-boundary regex for all names at once (alternation) if not by_name: return name_re = re.compile(r"\b(" + "|".join(re.escape(n) for n in by_name) + r")\b") for gd in gd_files: try: text = gd.read_text(encoding="utf-8", errors="replace") except OSError: continue for m in name_re.finditer(text): n = m.group(1) line_no = text.count("\n", 0, m.start()) + 1 for c in by_name[n]: c.callers.append((gd, line_no)) def parse_gd_class_names(gd_files: list[Path]) -> dict[str, Path]: out: dict[str, Path] = {} cn_re = re.compile(r"^\s*class_name\s+([A-Za-z_][A-Za-z0-9_]*)", re.M) for gd in gd_files: try: text = gd.read_text(encoding="utf-8", errors="replace") except OSError: continue m = cn_re.search(text) if m: out[m.group(1)] = gd return out def parse_crate_deps(sim_root: Path) -> dict[str, set[str]]: """Return crate_name → set of in-workspace crate deps.""" workspace_crates: set[str] = set() cargo_files: list[Path] = [] for d in sorted(sim_root.glob("crates/*")): if (d / "Cargo.toml").is_file(): workspace_crates.add(d.name) cargo_files.append(d / "Cargo.toml") for bridge in ("api-gdext", "api-wasm"): toml = sim_root / bridge / "Cargo.toml" if toml.is_file(): workspace_crates.add(bridge) cargo_files.append(toml) deps: dict[str, set[str]] = {} for toml in cargo_files: text = toml.read_text(encoding="utf-8", errors="replace") crate_name: str | None = None in_pkg = False in_dep = False local_deps: set[str] = set() for line in text.splitlines(): s = line.strip() if s.startswith("[package]"): in_pkg, in_dep = True, False continue if DEP_TABLE_RE.match(s): in_pkg, in_dep = False, True continue if s.startswith("[") and s.endswith("]"): # nested table like [dependencies.foo] still counts the prefix if s.startswith("[dependencies.") or s.startswith("[dev-dependencies."): name = s.split(".", 1)[1].rstrip("]") if name in workspace_crates: local_deps.add(name) in_pkg, in_dep = False, False continue in_pkg, in_dep = False, False continue if in_pkg and s.startswith("name"): m = re.search(r'"([^"]+)"', s) if m: crate_name = m.group(1) if in_dep: m = DEP_LINE_RE.match(s) if m and m.group(1) in workspace_crates: local_deps.add(m.group(1)) if crate_name: deps[crate_name] = local_deps return deps def build_json( classes: list[RustClass], gd_classes: dict[str, Path], deps: dict[str, set[str]], ) -> dict: rust_names = {c.name for c in classes} return { "generatedAt": __import__("datetime").datetime.utcnow().isoformat() + "Z", "classes": [ { "name": c.name, "rustStruct": c.rust_struct, "base": c.base, "file": str(c.file.relative_to(REPO_ROOT)), "line": c.line, "funcCount": c.func_count, "callerFiles": sorted( {str(p.relative_to(REPO_ROOT)) for p, _ in c.callers} ), "callerLines": [ {"file": str(p.relative_to(REPO_ROOT)), "line": ln} for p, ln in sorted(c.callers, key=lambda x: (str(x[0]), x[1])) ], } for c in sorted(classes, key=lambda x: x.name) ], "gdClassNames": { name: str(path.relative_to(REPO_ROOT)) for name, path in sorted(gd_classes.items()) }, "collisions": sorted(set(gd_classes) & rust_names), "crateDeps": { crate: sorted(ds) for crate, ds in sorted(deps.items()) }, } def render_report( classes: list[RustClass], gd_classes: dict[str, Path], deps: dict[str, set[str]], ) -> str: out: list[str] = [] out.append("# GDScript ↔ Rust Relationship Report\n") out.append( f"_Generated by `tools/gd-rust-relationships.py` over `{SIM_ROOT.relative_to(REPO_ROOT)}` and `src/game/`._\n" ) # 1. Bridge surface out.append("\n## 1. GDExtension surface (Rust → Godot)\n") out.append( f"**{len(classes)}** Godot-visible classes exported from " f"`src/simulator/api-gdext/`. Each row shows where the class is defined " "and how many `.gd` files reference it by name.\n" ) out.append("| Godot class | base | `#[func]` count | GD callers | Defined in |") out.append("|---|---|---:|---:|---|") for c in sorted(classes, key=lambda x: x.name): rel = c.file.relative_to(REPO_ROOT) gd_caller_files = {p for p, _ in c.callers} out.append( f"| `{c.name}` | `{c.base or '-'}` | {c.func_count} | " f"{len(gd_caller_files)} | `{rel}:{c.line}` |" ) # 2. Per-class GD callers out.append("\n## 2. Per-class GD callers\n") for c in sorted(classes, key=lambda x: x.name): gd_caller_files = sorted({p for p, _ in c.callers}) if not gd_caller_files: continue out.append(f"\n### `{c.name}`") out.append(f"_Rust struct `{c.rust_struct}` in `{c.file.relative_to(REPO_ROOT)}`._") for p in gd_caller_files: lines = sorted({ln for q, ln in c.callers if q == p}) line_str = ", ".join(f"L{ln}" for ln in lines[:8]) + ( f", … (+{len(lines)-8})" if len(lines) > 8 else "" ) out.append(f"- `{p.relative_to(REPO_ROOT)}` — {line_str}") # 3. Unused exports unused = [c for c in classes if not c.callers] out.append("\n## 3. Exported but unreferenced from any `.gd`\n") if not unused: out.append("_None — every exposed class has at least one GDScript reference._") else: for c in sorted(unused, key=lambda x: x.name): out.append( f"- `{c.name}` (`{c.file.relative_to(REPO_ROOT)}:{c.line}`)" ) # 4. GDScript class_name registry out.append("\n## 4. GDScript `class_name` declarations\n") out.append(f"**{len(gd_classes)}** GDScript classes declare a `class_name`. " "Names that collide with a Rust-exported class would shadow the binding:\n") rust_names = {c.name for c in classes} collisions = sorted(set(gd_classes) & rust_names) if collisions: out.append("**⚠ Name collisions:**") for n in collisions: out.append(f"- `{n}` — GD: `{gd_classes[n].relative_to(REPO_ROOT)}` / Rust: see §1") else: out.append("_No collisions._") # 5. Crate dependency graph out.append("\n## 5. Workspace crate dependency graph\n") out.append("```mermaid") out.append("graph LR") for crate, ds in sorted(deps.items()): if not ds: out.append(f' {crate.replace("-", "_")}["{crate}"]') for d in sorted(ds): out.append( f' {crate.replace("-", "_")}["{crate}"] --> ' f'{d.replace("-", "_")}["{d}"]' ) out.append("```") # 6. api-gdext crate fan-in bridges = {"api-gdext", "api-wasm"} out.append("\n## 6. Bridge-crate fan-in\n") for b in sorted(bridges): if b in deps: out.append(f"\n### `{b}` depends on:") for d in sorted(deps[b]): out.append(f"- `{d}`") out.append("") return "\n".join(out) def main() -> int: import json ap = argparse.ArgumentParser() ap.add_argument( "--out", type=Path, default=REPO_ROOT / ".project" / "reports" / "gd-rust-relationships.md", ) args = ap.parse_args() json_out = args.out.with_suffix(".json") if not GDEXT_SRC.is_dir(): print(f"error: {GDEXT_SRC} not found", file=sys.stderr) return 1 classes = find_godot_classes(GDEXT_SRC) gd_files = find_gd_files(GD_ROOTS) map_callers(classes, gd_files) gd_classes = parse_gd_class_names(gd_files) deps = parse_crate_deps(SIM_ROOT) args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(render_report(classes, gd_classes, deps), encoding="utf-8") json_out.write_text( json.dumps(build_json(classes, gd_classes, deps), indent=2), encoding="utf-8" ) referenced = sum(1 for c in classes if c.callers) print( f"Scanned {len(classes)} Godot-exposed Rust classes across " f"{len(list(GDEXT_SRC.rglob('*.rs')))} api-gdext files; " f"{referenced} referenced from {len(gd_files)} .gd files. " f"Crates: {len(deps)}. " f"Markdown: {args.out.relative_to(REPO_ROOT)} JSON: {json_out.relative_to(REPO_ROOT)}" ) return 0 if __name__ == "__main__": sys.exit(main())