#!/usr/bin/env python3 from __future__ import annotations import argparse import json import re from dataclasses import dataclass from pathlib import Path ROOT_DIR = Path(__file__).resolve().parent.parent CONFIG_PATH = ROOT_DIR / "global-usings.modules.json" AUTO_GENERATED_START = "" AUTO_GENERATED_END = "" @dataclass(frozen=True) class ModuleConfig: project_path: Path namespaces: tuple[str, ...] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Generate optional transitive global usings for packable GFramework runtime modules.", ) parser.add_argument( "--check", action="store_true", help="Validate that generated files are up to date instead of writing them.", ) return parser.parse_args() def load_text(path: Path) -> tuple[str, str]: raw = path.read_bytes() if raw.startswith(b"\xef\xbb\xbf"): return raw.decode("utf-8-sig"), "utf-8-sig" return raw.decode("utf-8"), "utf-8" def write_text(path: Path, content: str, encoding: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding=encoding) def load_modules() -> list[ModuleConfig]: data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) modules: list[ModuleConfig] = [] for entry in data["modules"]: project_path = ROOT_DIR / entry["project"] namespaces = tuple(dict.fromkeys(entry["namespaces"])) modules.append(ModuleConfig(project_path=project_path, namespaces=namespaces)) return modules def discover_runtime_modules() -> list[Path]: projects: list[Path] = [] for project_path in sorted(ROOT_DIR.rglob("*.csproj")): if "obj" in project_path.parts or "bin" in project_path.parts: continue if project_path.name == "GFramework.csproj": continue project_name = project_path.stem if not project_name.startswith("GFramework."): continue if ".Tests" in project_name or "SourceGenerators" in project_name: continue csproj_text, _ = load_text(project_path) if re.search(r"\s*false\s*", csproj_text, re.IGNORECASE): continue projects.append(project_path) return projects def resolve_package_id(project_path: Path) -> str: project_text, _ = load_text(project_path) match = re.search(r"(.*?)", project_text, re.DOTALL) if match is None: return project_path.stem package_id = match.group(1).strip() return package_id.replace("$(AssemblyName)", project_path.stem) def sanitize_identifier(text: str) -> str: return re.sub(r"[^A-Za-z0-9_]", "_", text) def render_props(module: ModuleConfig) -> str: item_name = f"_{sanitize_identifier(module.project_path.stem)}_TransitiveUsing" lines = [ "", " ", " ", " ", " ", ] for namespace in module.namespaces: lines.append(f" <{item_name} Include=\"{namespace}\" />") lines.extend( [ f" <{item_name} Remove=\"@(GFrameworkExcludedUsing)\" />", f" ", " ", "", "", ], ) return "\n".join(lines) def render_pack_block(package_id: str) -> str: props_path = f"buildTransitive\\{package_id}.props" return "\n".join( [ f" {AUTO_GENERATED_START}", " ", f" ", " ", f" {AUTO_GENERATED_END}", "", ], ) def update_csproj(project_path: Path, package_id: str) -> tuple[str, str]: project_text, encoding = load_text(project_path) block = render_pack_block(package_id) pattern = re.compile( rf"\s*{re.escape(AUTO_GENERATED_START)}.*?{re.escape(AUTO_GENERATED_END)}\s*", re.DOTALL, ) if pattern.search(project_text): updated = pattern.sub(lambda _: f"\n{block}", project_text) else: updated = project_text.replace("", f"{block}") return updated, encoding def validate_module_coverage(modules: list[ModuleConfig]) -> None: configured_projects = {module.project_path.resolve() for module in modules} discovered_projects = {project.resolve() for project in discover_runtime_modules()} missing = sorted(project.relative_to(ROOT_DIR) for project in discovered_projects - configured_projects) extra = sorted(project.relative_to(ROOT_DIR) for project in configured_projects - discovered_projects) if missing or extra: messages: list[str] = [] if missing: messages.append("Unconfigured runtime modules:\n - " + "\n - ".join(str(path) for path in missing)) if extra: messages.append("Configured modules that are not eligible runtime packages:\n - " + "\n - ".join(str(path) for path in extra)) raise SystemExit("\n".join(messages)) def check_or_write(path: Path, expected: str, encoding: str, check_only: bool, changed: list[Path]) -> None: if path.exists(): current, _ = load_text(path) if current == expected: return elif check_only: changed.append(path) return if check_only: changed.append(path) return write_text(path, expected, encoding) changed.append(path) def main() -> None: args = parse_args() modules = load_modules() validate_module_coverage(modules) changed: list[Path] = [] for module in modules: package_id = resolve_package_id(module.project_path) props_path = module.project_path.parent / "buildTransitive" / f"{package_id}.props" props_content = render_props(module) check_or_write(props_path, props_content, "utf-8", args.check, changed) updated_project, encoding = update_csproj(module.project_path, package_id) check_or_write(module.project_path, updated_project, encoding, args.check, changed) if args.check: if changed: relative_paths = "\n".join(f" - {path.relative_to(ROOT_DIR)}" for path in changed) raise SystemExit(f"Generated module global usings are out of date:\n{relative_paths}") print("Module global usings are up to date.") return if changed: for path in changed: print(f"Updated {path.relative_to(ROOT_DIR)}") return print("No module global usings changes were needed.") if __name__ == "__main__": main()