<?php
*
* ____ _ _ __ __ _ __ __ ____
* | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
* | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
* | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
* |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* @author PocketMine Team
* @link http://www.pocketmine.net/
*
*
*/
declare(strict_types=1);
namespace pocketmine\build\update_registry_interface;
use pocketmine\utils\AssumptionFailedError;
use pocketmine\utils\Filesystem;
use pocketmine\utils\RegistrySource;
use pocketmine\utils\Utils;
use Symfony\Component\Filesystem\Path;
use function basename;
use function class_exists;
use function count;
use function dirname;
use function file_exists;
use function file_get_contents;
use function file_put_contents;
use function fwrite;
use function implode;
use function interface_exists;
use function is_dir;
use function is_file;
use function ksort;
use function mb_strtoupper;
use function mkdir;
use function preg_match;
use function str_ends_with;
use function strcasecmp;
use function trait_exists;
use const SORT_STRING;
use const STDERR;
* To use this, you need a source class that has a @generate-registry-interface tag in its docblock.
* Docblock tag params: @generate-registry-interface <class name> <getAll function name> [preprocessor function name]
*
* In the source class, you need a function that returns an array<string, ?> of all the registry members you want to
* declare. Optionally, a preprocessor function can be specified that will be run on the value before it's returned
* from the accessor (e.g. if you need to clone it before returning it to the calling code).
*
* This script will generate a static class with a static function for every registry member you declared.
* The name will be passed through strtoupper, so e.g. "golden_apple" => new GoldenApple will generate
* public static function GOLDEN_APPLE() : GoldenApple{ ... }
*
* This approach has several advantages over the legacy docblock registry approach:
* - The script will never modify your handwritten class file
* - Generated code can be kept separate from the source code
* - Generated code like this is significantly faster than the old __callStatic() approach
*
* You can see VanillaBlocksInputs (source example) and VanillaBlocks (generated example) for a look at how this works.
*/
if(!isset($argv) || count($argv) !== 3){
fwrite(STDERR, "Usage: " . __FILE__ . " <source file/folder> <destination file/folder>\n");
exit(1);
}
* @phpstan-param RegistrySource<*> $registrySource
*/
function generateRegistryInterface(string $namespaceName, string $sourceShortClassName, RegistrySource $registrySource, string $fileHeader) : string{
$selfName = basename(__FILE__);
$importClasses = [
$namespaceName . "\\" . $sourceShortClassName => true
];
$importFunctions = [
"mb_strtoupper" => true,
"array_keys" => true,
"count" => true,
"implode" => true,
];
$output = $fileHeader . <<<HEADER
namespace $namespaceName;
HEADER;
$interfaceShortClassName = $registrySource->getTargetClassName();
$startClass = <<<CLASS
* This class is generated automatically from source class {@link $sourceShortClassName}. Do not modify it manually.
* It must be regenerated whenever the source class is changed.
* @see build/codegen/$selfName
*/
final class $interfaceShortClassName{
CLASS;
$docCommentPrepend = "";
$docCommentLines = $registrySource->getTargetClassDocComment();
if(count($docCommentLines) > 0){
foreach($docCommentLines as $line){
$docCommentPrepend .= " * $line\n";
}
$docCommentPrepend .= " *\n";
}
$memberLines = [];
$propertyLines = [];
$assignLines = [];
$preprocessorReflect = (new \ReflectionFunction($registrySource::preprocessMember(...)));
if($preprocessorReflect->getClosureScopeClass()?->getName() !== RegistrySource::class){
$preprocessorFunc = $preprocessorReflect->getName();
$preprocessorPrefix = "$sourceShortClassName::$preprocessorFunc(";
$preprocessorSuffix = ")";
$preprocessorMapper = "array_map($sourceShortClassName::$preprocessorFunc(...), self::\$members)";
$importFunctions["array_map"] = true;
}else{
$preprocessorPrefix = "";
$preprocessorSuffix = "";
$preprocessorMapper = "self::\$members";
}
if($registrySource->cloneResults()){
$preprocessorPrefix = "clone $preprocessorPrefix";
$preprocessorMapper = "Utils::cloneObjectArray($preprocessorMapper)";
$importClasses[Utils::class] = true;
}
$commonParent = null;
foreach(Utils::stringifyKeys($registrySource->getAllDeclarations()) as $name => $memberTypes){
if(count($memberTypes) === 0){
$typehint = "object";
$commonParent = false;
}else{
$shortTypes = [];
foreach($memberTypes as $memberType){
if(!class_exists($memberType) && !interface_exists($memberType)){
throw new \LogicException("Invalid type for member \"$name\", expected only classes/interfaces, but got: $memberType");
}
$reflect = new \ReflectionClass($memberType);
$shortTypes[] = $reflect->getShortName();
$importClasses[$reflect->getName()] = true;
}
$typehint = implode("&", $shortTypes);
$concreteClass = null;
foreach($memberTypes as $memberType){
if(class_exists($memberType)){
if($concreteClass === null){
$concreteClass = new \ReflectionClass($memberType);
}else{
throw new AssumptionFailedError("Two base classes for registry member \$name\" in source $sourceShortClassName???");
}
}
}
if($commonParent === null){
$commonParent = $concreteClass;
}elseif($commonParent !== false){
if($concreteClass === null){
$commonParent = false;
}else{
while($commonParent !== false && !$concreteClass->isSubclassOf($commonParent) && $concreteClass->getName() !== $commonParent->getName()){
$commonParent = $commonParent->getParentClass();
}
}
}
}
$accessor = mb_strtoupper($name);
$propertyLines[$accessor] = "\tprivate static $typehint \$_m$accessor;\n";
$assignLines[$accessor] = "\t\t\t\"$name\" => fn($typehint \$v) => self::\$_m$accessor = \$v,\n";
$memberLines[$accessor] = <<<TEMPLATE
public static function $accessor() : $typehint{
if(!isset(self::\$_m$accessor)){ self::init(); }
return {$preprocessorPrefix}self::\$_m{$accessor}{$preprocessorSuffix};
}
TEMPLATE;
}
if($commonParent !== false){
if($commonParent === null){
throw new AssumptionFailedError("Unreachable");
}
$importClasses[$commonParent->getName()] = true;
}
ksort($importClasses, SORT_STRING);
$imports = 0;
foreach(Utils::stringifyKeys($importClasses) as $import => $_){
if(!class_exists($import) && !interface_exists($import) && !trait_exists($import)){
throw new AssumptionFailedError("Class $import does not exist");
}
$reflect = new \ReflectionClass($import);
if($reflect->getNamespaceName() === $namespaceName){
continue;
}
$output .= "\nuse $import;";
$imports++;
}
ksort($importFunctions, SORT_STRING);
foreach(Utils::stringifyKeys($importFunctions) as $import => $_){
$output .= "\nuse function $import;";
$imports++;
}
if($imports > 0){
$output .= "\n";
}
$output .= "\n/**\n" . $docCommentPrepend . $startClass;
ksort($propertyLines, SORT_STRING);
$output .= implode("", $propertyLines);
$getAllTypehint = $commonParent !== false ? $commonParent->getShortName() : "object";
$output .= <<<INIT
/**
* @var {$getAllTypehint}[]
* @phpstan-var array<string, {$getAllTypehint}>
*/
private static array \$members;
private static bool \$initialized = false;
private function __construct(){
//NOOP
}
/**
* Hack to allow ignoring PHPStan wrong type assignment error in one place instead of hundreds or thousands
* Assumes that the input value already matches the expected type. If not, a TypeError will be thrown on assignment.
*
* @phpstan-param \Closure(never) : $getAllTypehint \$closure
*/
private static function unsafeAssign(\Closure \$closure, {$getAllTypehint} \$memberValue) : void{
/**
* This type is not correct either (the param is actually a subtype of $getAllTypehint) but it's called
* unsafeAssign for a reason :)
* @phpstan-var \Closure($getAllTypehint) : $getAllTypehint \$closure
*/
\$closure(\$memberValue);
}
/**
* @return \Closure[]
* @phpstan-return array<string, \Closure(never) : $getAllTypehint>
*/
private static function getInitAssigners() : array{
return [
INIT;
ksort($assignLines, SORT_STRING);
$output .= implode("", $assignLines);
$output .= <<<INIT2
];
}
private static function init() : void{
//This nasty mess of closures allows us to suppress PHPStan type assignment errors in one place instead of
//on every single assignment. This will only run one time on first init, so it's fine for performance.
if(self::\$initialized){
throw new \LogicException("Circular dependency detected - use RegistrySource->registerDelayed() if the circular dependency can't be avoided");
}
self::\$initialized = true;
\$assigners = self::getInitAssigners();
\$assigned = [];
\$source = new $sourceShortClassName();
foreach(\$source->getAllValues() as \$name => \$value){
\$assigner = \$assigners[\$name] ?? throw new \LogicException("Unexpected source registry member \"\$name\" (code probably needs regenerating)");
if(isset(\$assigned[\$name])){
//this should be prevented by RegistrySource, but it doesn't hurt to have some redundancy
throw new \LogicException("Repeated registry source member \"\$name\"");
}
self::\$members[mb_strtoupper(\$name)] = \$value;
\$assigned[\$name] = true;
unset(\$assigners[\$name]);
self::unsafeAssign(\$assigner, \$value);
}
if(count(\$assigners) > 0){
throw new \LogicException("Missing values for registry members (code probably needs regenerating): " . implode(", ", array_keys(\$assigners)));
}
}
/**
* @return {$getAllTypehint}[]
* @phpstan-return array<string, {$getAllTypehint}>
*/
public static function getAll() : array{
if(!isset(self::\$members)){ self::init(); }
return $preprocessorMapper;
}
INIT2;
ksort($memberLines, SORT_STRING);
$output .= implode("\n\n", $memberLines);
$output .= "\n}\n";
return $output;
}
function processFile(string $file, string $sourceDir, string $outputDir, string $fileHeader) : void{
$contents = file_get_contents($file);
if($contents === false){
throw new \RuntimeException("Failed to get contents of $file");
}
if(preg_match("/(*ANYCRLF)^namespace (.+);$/m", $contents, $matches) !== 1 || preg_match('/(*ANYCRLF)^((final|abstract)\s+)?class /m', $contents) !== 1){
return;
}
$shortClassName = basename($file, ".php");
$namespace = $matches[1];
$className = $namespace . "\\" . $shortClassName;
if(!class_exists($className)){
return;
}
$reflect = new \ReflectionClass($className);
if(!$reflect->isSubclassOf(RegistrySource::class) || !$reflect->isInstantiable()){
return;
}
$source = $reflect->newInstance();
$interfaceClassName = $source->getTargetClassName();
if(preg_match('/^[A-Za-z\d_]+$/', $interfaceClassName) !== 1){
throw new \RuntimeException("Generated class name $interfaceClassName must contain only letters, numbers and underscores");
}
$relativeDir = Path::makeRelative(dirname($file), $sourceDir);
$generatedRelativeDir = Path::join($outputDir, $relativeDir);
if(!@mkdir($generatedRelativeDir, recursive: true) && !is_dir($generatedRelativeDir)){
throw new \RuntimeException("Failed to create target dir $generatedRelativeDir for generated file for $file");
}
$generatedFile = Path::join($generatedRelativeDir, $interfaceClassName . ".php");
$conflictCheckFile = Path::join(dirname($file), $interfaceClassName . ".php");
if($generatedFile !== $conflictCheckFile && file_exists($conflictCheckFile)){
throw new \RuntimeException("Generated class $interfaceClassName seems to conflict with an existing non-generated class: $conflictCheckFile\n");
}
echo "Found registry in $file, will generate interface in $generatedFile\n";
if(strcasecmp($interfaceClassName, $shortClassName) === 0){
throw new \RuntimeException("Generated class name $interfaceClassName cannot be the same as the interface class name (file $file)");
}
try{
$oldContents = Filesystem::fileGetContents($generatedFile);
}catch(\RuntimeException){
$oldContents = "";
}
$newContents = generateRegistryInterface($namespace, $shortClassName, $source, $fileHeader);
if($newContents !== $oldContents){
echo "Writing changed file $generatedFile\n";
file_put_contents($generatedFile, $newContents);
}else{
echo "No changes needed for file $generatedFile\n";
}
}
require dirname(__DIR__, 2) . '/vendor/autoload.php';
$fileHeader = Filesystem::fileGetContents(__DIR__ . "/templates/header.php");
if(is_dir($argv[1])){
if(file_exists($argv[2]) && !is_dir($argv[2])){
fwrite(STDERR, "Destination for generated files isn't a folder: " . $argv[2] . "\n");
exit(1);
}
foreach(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($argv[1], \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME)) as $file){
if(!str_ends_with($file, ".php")){
continue;
}
processFile($file, $argv[1], $argv[2], $fileHeader);
}
}else{
if(file_exists($argv[2]) && !is_file($argv[2])){
fwrite(STDERR, "Destination for generated file already exists and is not a file: " . $argv[2] . "\n");
exit(1);
}
processFile($argv[1], $argv[1], $argv[2], $fileHeader);
}