#!/usr/bin/env python3 # Copyright 2025 Timothé Lapetite and contributors # Released under the MIT license https://opensource.org/license/MIT/ # NOTE : This code was generated by Claude AI. import json import os import re import shutil import sys from pathlib import Path PLUGIN_NAME = "PCGExtendedToolkit" MODULE_PREFIX = "PCGEx" EDITOR_SUFFIX = "Editor" PLATFORMS = { "runtime": ["Win64", "Mac", "IOS", "Android", "Linux", "LinuxArm64"], "editor": ["Win64", "Mac", "Linux"], } # Resolve paths relative to script location (assumes script is in plugin root or Config/) script_dir = Path(__file__).parent.resolve() plugin_root = ( script_dir if (script_dir / f"{PLUGIN_NAME}.uplugin").exists() else script_dir.parent ) # Project root is typically two levels up from plugin (Plugins/PCGExtendedToolkit/) project_root = plugin_root.parent.parent PATHS = { "uplugin": plugin_root / f"{PLUGIN_NAME}.uplugin", "modules_dir": plugin_root / "Source", "binaries": plugin_root / "Binaries", "intermediate": plugin_root / "Intermediate", # Project-level config takes priority, falls back to plugin config "project_submodules_config": project_root / "Config" / "PCGExSubModulesConfig.ini", "plugin_submodules_config": plugin_root / "Config" / "PCGExSubModulesConfig.ini", "plugins_deps": plugin_root / "Config" / "PluginsDeps.ini", } # ============================================================================= # Fuzzy Matching # ============================================================================= def levenshtein_distance(a: str, b: str) -> int: if len(a) < len(b): a, b = b, a if len(b) == 0: return len(a) prev_row = list(range(len(b) + 1)) for i, ca in enumerate(a): curr_row = [i + 1] for j, cb in enumerate(b): insertions = prev_row[j + 1] + 1 deletions = curr_row[j] + 1 substitutions = prev_row[j] + (ca != cb) curr_row.append(min(insertions, deletions, substitutions)) prev_row = curr_row return prev_row[-1] def find_closest_match(name: str, candidates: list[str], max_distance: int = 3) -> str | None: best = None best_distance = float("inf") for candidate in candidates: distance = levenshtein_distance(name.lower(), candidate.lower()) if distance < best_distance and distance <= max_distance: best_distance = distance best = candidate return best # ============================================================================= # INI Parsing # ============================================================================= def resolve_submodules_config_path() -> tuple[Path, bool] | None: """Returns (path, is_project_level) or None if not found.""" # Check project-level config first if PATHS["project_submodules_config"].exists(): return (PATHS["project_submodules_config"], True) # Fall back to plugin config if PATHS["plugin_submodules_config"].exists(): return (PATHS["plugin_submodules_config"], False) return None def parse_submodules_config(file_path: Path) -> list[str]: modules = [] content = file_path.read_text(encoding="utf-8") for line in content.splitlines(): trimmed = line.strip() if not trimmed or trimmed.startswith("#") or trimmed.startswith(";"): continue # Support both "ModuleName=1" and plain "ModuleName" formats parts = trimmed.split("=") module_name = parts[0].strip() if len(parts) == 1 or parts[1].strip() == "1": if module_name.startswith(MODULE_PREFIX): modules.append(module_name) return modules def parse_plugins_deps(file_path: Path) -> dict[str, list[str]]: deps: dict[str, list[str]] = {} if not file_path.exists(): print(f"PluginsDeps.ini not found at: {file_path} - skipping plugin dependencies") return deps content = file_path.read_text(encoding="utf-8") current_module = None for line in content.splitlines(): trimmed = line.strip() if not trimmed or trimmed.startswith("#") or trimmed.startswith(";"): continue # Section header: [ModuleName] section_match = re.match(r"^\[(.+)\]$", trimmed) if section_match: current_module = section_match.group(1) if current_module not in deps: deps[current_module] = [] continue # Plugin name under current section if current_module and trimmed: deps[current_module].append(trimmed) return deps # ============================================================================= # Module Discovery # ============================================================================= def get_available_modules() -> list[str]: modules_dir = PATHS["modules_dir"] if not modules_dir.exists(): return [] return [ name for name in os.listdir(modules_dir) if (modules_dir / name).is_dir() and name.startswith(MODULE_PREFIX) ] def module_exists(module_name: str) -> bool: module_path = PATHS["modules_dir"] / module_name return module_path.is_dir() def get_editor_companion(module_name: str) -> str | None: if module_name.endswith(EDITOR_SUFFIX): return None editor_name = module_name + EDITOR_SUFFIX return editor_name if module_exists(editor_name) else None def scan_build_cs_dependencies(module_name: str) -> list[str]: build_file = PATHS["modules_dir"] / module_name / f"{module_name}.Build.cs" if not build_file.exists(): return [] content = build_file.read_text(encoding="utf-8") deps = [] # Match: PublicDependencyModuleNames.AddRange(new[] { ... }) or similar block_pattern = re.compile( r"(?:Public|Private)DependencyModuleNames\s*\.\s*AddRange\s*\(\s*new\s*(?:string)?\s*\[\s*\]\s*\{([^}]*)\}", re.DOTALL, ) name_pattern = re.compile(r'"(PCGEx\w+)"') for block_match in block_pattern.finditer(content): array_content = block_match.group(1) for name_match in name_pattern.finditer(array_content): dep = name_match.group(1) if dep != module_name and dep not in deps: deps.append(dep) return deps # ============================================================================= # Module Resolution # ============================================================================= def validate_requested_modules( requested_modules: list[str], available_modules: list[str] ) -> tuple[list[str], list[dict]]: valid = [] invalid = [] for module_name in requested_modules: if module_exists(module_name): valid.append(module_name) else: suggestion = find_closest_match(module_name, available_modules) invalid.append({"name": module_name, "suggestion": suggestion}) return valid, invalid def resolve_all_modules(requested_modules: list[str]) -> set[str]: resolved: set[str] = set() queue = list(requested_modules) while queue: module_name = queue.pop(0) if module_name in resolved: continue if not module_exists(module_name): continue # Already warned during validation resolved.add(module_name) # Add editor companion if exists editor = get_editor_companion(module_name) if editor and editor not in resolved: queue.append(editor) # Add PCGEx dependencies deps = scan_build_cs_dependencies(module_name) for dep in deps: if dep not in resolved: queue.append(dep) return resolved # ============================================================================= # Uplugin Generation # ============================================================================= def load_existing_uplugin() -> str: uplugin_path = PATHS["uplugin"] if not uplugin_path.exists(): print(f".uplugin not found at: {uplugin_path}", file=sys.stderr) sys.exit(1) return uplugin_path.read_text(encoding="utf-8") def build_module_entry(name: str) -> dict: is_editor = name.endswith(EDITOR_SUFFIX) return { "Name": name, "Type": "Editor" if is_editor else "Runtime", "LoadingPhase": "Default", "PlatformAllowList": list(PLATFORMS["editor"] if is_editor else PLATFORMS["runtime"]), } def build_plugin_entry(name: str) -> dict: return {"Name": name, "Enabled": True} def resolve_required_plugins(modules: set[str], plugins_deps: dict[str, list[str]]) -> set[str]: plugins = {"PCG"} # PCG is always required for module_name in modules: required = plugins_deps.get(module_name) if required: plugins.update(required) return plugins def module_sort_key(name: str) -> tuple[int, int, str]: is_umbrella = name == PLUGIN_NAME or name == PLUGIN_NAME + EDITOR_SUFFIX if is_umbrella: # Main umbrella first (0), then editor umbrella (1) return (0, 0 if name == PLUGIN_NAME else 1, name) return (1, 0, name) def generate_uplugin(existing_uplugin: dict, modules: set[str], plugins: set[str]) -> dict: # Always include umbrella modules all_modules = set(modules) all_modules.add(PLUGIN_NAME) all_modules.add(PLUGIN_NAME + EDITOR_SUFFIX) # Build new uplugin preserving all existing metadata new_uplugin = dict(existing_uplugin) new_uplugin["Modules"] = [ build_module_entry(name) for name in sorted(all_modules, key=module_sort_key) ] def plugin_sort_key(name: str) -> tuple[int, str]: return (0, name) if name == "PCG" else (1, name) new_uplugin["Plugins"] = [ build_plugin_entry(name) for name in sorted(plugins, key=plugin_sort_key) ] return new_uplugin # ============================================================================= # Build Artifact Cleanup # ============================================================================= def delete_folder(folder_path: Path) -> bool: if not folder_path.exists(): return False shutil.rmtree(folder_path) return True def clean_build_artifacts(): print("\n[CLEANUP] .uplugin changed - invalidating build artifacts...") cleaned = False if delete_folder(PATHS["binaries"]): print(f" Deleted: {PATHS['binaries']}") cleaned = True if delete_folder(PATHS["intermediate"]): print(f" Deleted: {PATHS['intermediate']}") cleaned = True if not cleaned: print(" No build artifacts to clean.") # ============================================================================= # Main # ============================================================================= def main(): print(f"Generating {PLUGIN_NAME}.uplugin...") print(f"Plugin root: {plugin_root}") print(f"Project root: {project_root}") # Resolve config path (project-level or plugin-level) config_info = resolve_submodules_config_path() if not config_info: print("PCGExSubModulesConfig.ini not found.", file=sys.stderr) print("Searched:", file=sys.stderr) print(f" - {PATHS['project_submodules_config']}", file=sys.stderr) print(f" - {PATHS['plugin_submodules_config']}", file=sys.stderr) sys.exit(1) config_path, is_project_level = config_info config_source = "project" if is_project_level else "plugin" print(f"Using {config_source}-level config: {config_path}") # Get available modules for validation and suggestions available_modules = get_available_modules() print(f"Available modules on disk: {len(available_modules)}") # Parse configs requested_modules = parse_submodules_config(config_path) print(f"Requested modules: {len(requested_modules)}") # Validate requested modules valid, invalid = validate_requested_modules(requested_modules, available_modules) if invalid: print("\n[WARNING] Invalid module names in PCGExSubModulesConfig.ini:", file=sys.stderr) for item in invalid: name = item["name"] suggestion = item["suggestion"] if suggestion: print(f" - '{name}' not found. Did you mean '{suggestion}'?", file=sys.stderr) else: print(f" - '{name}' not found.", file=sys.stderr) print(file=sys.stderr) plugins_deps = parse_plugins_deps(PATHS["plugins_deps"]) print(f"Plugin dependencies defined for: {len(plugins_deps)} modules") # Resolve all modules (with deps and editor companions) all_modules = resolve_all_modules(valid) print(f"Resolved modules (with dependencies): {len(all_modules)}") # Resolve required plugins required_plugins = resolve_required_plugins(all_modules, plugins_deps) print(f"Required plugins: {', '.join(sorted(required_plugins))}") # Load existing uplugin content (as string for comparison) existing_content = load_existing_uplugin() existing_uplugin = json.loads(existing_content) # Generate new uplugin new_uplugin = generate_uplugin(existing_uplugin, all_modules, required_plugins) new_content = json.dumps(new_uplugin, indent=2, ensure_ascii=False) # Check if content changed has_changed = existing_content != new_content if has_changed: # Clean build artifacts before writing new uplugin # clean_build_artifacts() # Write new uplugin PATHS["uplugin"].write_text(new_content, encoding="utf-8") print(f"\nGenerated {PATHS['uplugin']}") else: print("\n.uplugin unchanged - skipping write.") print(f" Modules: {len(new_uplugin['Modules'])}") print(f" Plugins: {len(new_uplugin['Plugins'])}") # Exit with warning code if there were invalid modules if invalid: sys.exit(0) # Still succeeds, but you could use sys.exit(1) to fail the build if __name__ == "__main__": main()