magicciv/tools/gd-rust-relationships.py

376 lines
14 KiB
Python
Raw Permalink Normal View History

#!/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())