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