375 lines
14 KiB
Python
Executable file
375 lines
14 KiB
Python
Executable file
#!/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())
|