diff --git a/composer.json b/composer.json index cd4cf1e4..53c9ad75 100644 --- a/composer.json +++ b/composer.json @@ -19,8 +19,8 @@ "nette/utils": "^3.1.2" }, "require-dev": { - "nette/tester": "^2.0", - "nikic/php-parser": "^4.4", + "nette/tester": "^2.4", + "nikic/php-parser": "^4.11", "tracy/tracy": "^2.3", "phpstan/phpstan": "^0.12" }, diff --git a/readme.md b/readme.md index 79dd8948..2295ec47 100644 --- a/readme.md +++ b/readme.md @@ -706,6 +706,30 @@ $class = Nette\PhpGenerator\ClassType::withBodiesFrom(MyClass::class); $function = Nette\PhpGenerator\GlobalFunction::withBodyFrom('dump'); ``` +Load class from file +-------------------- + +You can also load classes directly from a PHP file that is not already loaded or string of PHP code: + +```php +$class = Nette\PhpGenerator\ClassType::fromCode(<<fromClassCode($code); + } + + public function __construct(string $name = null, PhpNamespace $namespace = null) { $this->setName($name); diff --git a/src/PhpGenerator/Extractor.php b/src/PhpGenerator/Extractor.php index c02f3cf7..68d85351 100644 --- a/src/PhpGenerator/Extractor.php +++ b/src/PhpGenerator/Extractor.php @@ -26,13 +26,15 @@ final class Extractor private $code; private $statements; + private $printer; public function __construct(string $code) { if (!class_exists(ParserFactory::class)) { - throw new Nette\NotSupportedException("PHP-Parser is required to load method bodies, install package 'nikic/php-parser'."); + throw new Nette\NotSupportedException("PHP-Parser is required to load method bodies, install package 'nikic/php-parser' 4.7 or newer."); } + $this->printer = new PhpParser\PrettyPrinter\Standard; $this->parseCode($code); } @@ -40,7 +42,7 @@ public function __construct(string $code) private function parseCode(string $code): void { $this->code = str_replace("\r\n", "\n", $code); - $lexer = new PhpParser\Lexer(['usedAttributes' => ['startFilePos', 'endFilePos']]); + $lexer = new PhpParser\Lexer\Emulative(['usedAttributes' => ['startFilePos', 'endFilePos', 'comments']]); $parser = (new ParserFactory)->create(ParserFactory::ONLY_PHP7, $lexer); $stmts = $parser->parse($this->code); @@ -167,6 +169,226 @@ private function performReplacements(string $s, array $replacements): string } + public function extractClasses(): PhpFile + { + $phpFile = new PhpFile; + $namespace = ''; + (new NodeFinder)->find($this->statements, function (Node $node) use (&$namespace, $phpFile) { + if ($node instanceof Node\Stmt\Namespace_) { + $namespace = $node->name ? $node->name->toString() : ''; + } elseif ($node instanceof Node\Stmt\Use_) { + $this->addUseToNamespace($node, $phpFile->addNamespace($namespace)); + } elseif ($node instanceof Node\Stmt\Class_) { + $this->addClassToFile($phpFile, $node); + } elseif ($node instanceof Node\Stmt\Interface_) { + $this->addInterfaceToFile($phpFile, $node); + } elseif ($node instanceof Node\Stmt\Trait_) { + $this->addTraitToFile($phpFile, $node); + } elseif ($node instanceof Node\Stmt\Enum_) { + $this->addEnumToFile($phpFile, $node); + } + }); + return $phpFile; + } + + + /** + * @param Node\Stmt\Class_|Node\Stmt\Interface_|Node\Stmt\Trait_|Node\Stmt\Enum_ $node + */ + public function addMembers(ClassType $class, $node): void + { + (new NodeFinder)->find($node, function (Node $node) use ($class) { + if ($node instanceof Node\Stmt\TraitUse) { + $this->addTraitToClass($class, $node); + } elseif ($node instanceof Node\Stmt\Property) { + $this->addPropertyToClass($class, $node); + } elseif ($node instanceof Node\Stmt\ClassMethod) { + $this->addMethodToClass($class, $node); + } elseif ($node instanceof Node\Stmt\ClassConst) { + $this->addConstantToClass($class, $node); + } elseif ($node instanceof Node\Stmt\EnumCase) { + $this->addEnumCaseToClass($class, $node); + } + }); + } + + + private function addUseToNamespace(Node\Stmt\Use_ $node, PhpNamespace $namespace): void + { + if ($node->type === $node::TYPE_NORMAL) { + foreach ($node->uses as $use) { + $namespace->addUse($use->name->toString(), $use->alias ? $use->alias->toString() : null); + } + } + } + + + private function addClassToFile(PhpFile $phpFile, Node\Stmt\Class_ $node): ClassType + { + $class = $phpFile->addClass($node->namespacedName->toString()); + if ($node->extends) { + $class->setExtends($node->extends->toString()); + } + foreach ($node->implements as $item) { + $class->addImplement($item->toString()); + } + $class->setFinal($node->isFinal()); + $class->setAbstract($node->isAbstract()); + $this->addCommentAndAttributes($class, $node); + $this->addMembers($class, $node); + return $class; + } + + + private function addInterfaceToFile(PhpFile $phpFile, Node\Stmt\Interface_ $node): ClassType + { + $class = $phpFile->addInterface($node->namespacedName->toString()); + foreach ($node->extends as $item) { + $class->addExtend($item->toString()); + } + $this->addCommentAndAttributes($class, $node); + $this->addMembers($class, $node); + return $class; + } + + + private function addTraitToFile(PhpFile $phpFile, Node\Stmt\Trait_ $node): ClassType + { + $class = $phpFile->addTrait($node->namespacedName->toString()); + $this->addCommentAndAttributes($class, $node); + $this->addMembers($class, $node); + return $class; + } + + + private function addEnumToFile(PhpFile $phpFile, Node\Stmt\Enum_ $node): ClassType + { + $class = $phpFile->addEnum($node->namespacedName->toString()); + foreach ($node->implements as $item) { + $class->addImplement($item->toString()); + } + $this->addCommentAndAttributes($class, $node); + $this->addMembers($class, $node); + return $class; + } + + + private function addTraitToClass(ClassType $class, Node\Stmt\TraitUse $node): void + { + $res = []; + foreach ($node->adaptations as $item) { + $res[] = trim($this->toPhp($item), ';'); + } + foreach ($node->traits as $trait) { + $class->addTrait($trait->toString(), $res); + $res = []; + } + } + + + private function addPropertyToClass(ClassType $class, Node\Stmt\Property $node): void + { + foreach ($node->props as $item) { + $prop = $class->addProperty($item->name->toString()); + $prop->setStatic($node->isStatic()); + if ($node->isPrivate()) { + $prop->setPrivate(); + } elseif ($node->isProtected()) { + $prop->setProtected(); + } + $prop->setType($node->type ? $this->toPhp($node->type) : null); + if ($item->default) { + $prop->setValue(new Literal($this->toPhp($item->default))); + } + $prop->setReadOnly(method_exists($node, 'isReadonly') && $node->isReadonly()); + $this->addCommentAndAttributes($prop, $node); + } + } + + + private function addMethodToClass(ClassType $class, Node\Stmt\ClassMethod $node): void + { + $method = $class->addMethod($node->name->toString()); + $method->setAbstract($node->isAbstract()); + $method->setFinal($node->isFinal()); + $method->setStatic($node->isStatic()); + if ($node->isPrivate()) { + $method->setPrivate(); + } elseif ($node->isProtected()) { + $method->setProtected(); + } + $method->setReturnReference($node->returnsByRef()); + $method->setReturnType($node->getReturnType() ? $this->toPhp($node->getReturnType()) : null); + foreach ($node->params as $item) { + $param = $method->addParameter($item->var->name); + $param->setType($item->type ? $this->toPhp($item->type) : null); + $param->setReference($item->byRef); + $method->setVariadic($item->variadic); + if ($item->default) { + $param->setDefaultValue(new Literal($this->toPhp($item->default))); + } + $this->addCommentAndAttributes($param, $item); + } + $this->addCommentAndAttributes($method, $node); + if ($node->stmts) { + $method->setBody($this->getReformattedBody($node->stmts, 2)); + } + } + + + private function addConstantToClass(ClassType $class, Node\Stmt\ClassConst $node): void + { + foreach ($node->consts as $item) { + $const = $class->addConstant($item->name->toString(), new Literal($this->toPhp($item->value))); + if ($node->isPrivate()) { + $const->setPrivate(); + } elseif ($node->isProtected()) { + $const->setProtected(); + } + $const->setFinal(method_exists($node, 'isFinal') && $node->isFinal()); + $this->addCommentAndAttributes($const, $node); + } + } + + + private function addEnumCaseToClass(ClassType $class, Node\Stmt\EnumCase $node) + { + $case = $class->addCase($node->name->toString(), $node->expr ? $node->expr->value : null); + $this->addCommentAndAttributes($case, $node); + } + + + private function addCommentAndAttributes($element, Node $node): void + { + if ($node->getDocComment()) { + $comment = $node->getDocComment()->getReformattedText(); + $comment = Helpers::unformatDocComment($comment); + $element->setComment($comment); + } + + foreach ($node->attrGroups ?? [] as $group) { + foreach ($group->attrs as $attribute) { + $args = []; + foreach ($attribute->args as $arg) { + $value = new Literal($this->toPhp($arg)); + if ($arg->name) { + $args[$arg->name->toString()] = $value; + } else { + $args[] = $value; + } + } + $element->addAttribute($attribute->name->toString(), $args); + } + } + } + + + private function toPhp($value): string + { + return $this->printer->prettyPrint([$value]); + } + + private function getNodeContents(Node ...$nodes): string { $start = $nodes[0]->getStartFilePos(); diff --git a/src/PhpGenerator/Factory.php b/src/PhpGenerator/Factory.php index 689050db..a67dbebe 100644 --- a/src/PhpGenerator/Factory.php +++ b/src/PhpGenerator/Factory.php @@ -279,6 +279,23 @@ public function fromPropertyReflection(\ReflectionProperty $from): Property } + public function fromClassCode(string $code): ClassType + { + $classes = $this->fromCode($code)->getClasses(); + if (!$classes) { + throw new Nette\InvalidStateException('The code does not contain any class.'); + } + return reset($classes); + } + + + public function fromCode(string $code): PhpFile + { + $reader = new Extractor($code); + return $reader->extractClasses(); + } + + private function getAttributes($from): array { if (PHP_VERSION_ID < 80000) { diff --git a/src/PhpGenerator/PhpFile.php b/src/PhpGenerator/PhpFile.php index 59401cad..ded320d0 100644 --- a/src/PhpGenerator/PhpFile.php +++ b/src/PhpGenerator/PhpFile.php @@ -32,6 +32,12 @@ final class PhpFile private $strictTypes = false; + public static function fromCode(string $code): self + { + return (new Factory)->fromCode($code); + } + + public function addClass(string $name): ClassType { return $this diff --git a/tests/PhpGenerator/ClassType.fromCode.phpt b/tests/PhpGenerator/ClassType.fromCode.phpt new file mode 100644 index 00000000..9cfc1575 --- /dev/null +++ b/tests/PhpGenerator/ClassType.fromCode.phpt @@ -0,0 +1,28 @@ +fromClassCode(file_get_contents(__DIR__ . '/fixtures/classes.php')); +Assert::type(Nette\PhpGenerator\ClassType::class, $class); +Assert::match(<<<'XX' +/** + * Interface + * @author John Doe + */ +interface Interface1 +{ + function func1(); +} +XX +, (string) $class); + + +$file = $factory->fromCode(file_get_contents(__DIR__ . '/fixtures/classes.php')); +Assert::type(Nette\PhpGenerator\PhpFile::class, $file); +sameFile(__DIR__ . '/expected/Factory.fromCode.expect', (string) $file); + +$file = $factory->fromCode(file_get_contents(__DIR__ . '/fixtures/classes.74.php')); +sameFile(__DIR__ . '/expected/Factory.fromCode.74.expect', (string) $file); + +$file = $factory->fromCode(file_get_contents(__DIR__ . '/fixtures/classes.80.php')); +sameFile(__DIR__ . '/expected/Factory.fromCode.80.expect', (string) $file); + +//$file = $factory->fromCode(file_get_contents(__DIR__ . '/fixtures/classes.81.php')); +//sameFile(__DIR__ . '/expected/Factory.fromCode.81.expect', (string) $file); + +$file = $factory->fromCode(file_get_contents(__DIR__ . '/fixtures/enum.php')); +sameFile(__DIR__ . '/expected/Factory.fromCode.enum.expect', (string) $file); + +$file = $factory->fromCode(file_get_contents(__DIR__ . '/fixtures/traits.php')); +sameFile(__DIR__ . '/expected/Factory.fromCode.traits.expect', (string) $file); + +$file = $factory->fromCode(file_get_contents(__DIR__ . '/fixtures/bodies.php')); +sameFile(__DIR__ . '/expected/Factory.fromCode.bodies.expect', (string) $file); diff --git a/tests/PhpGenerator/PhpFile.phpt b/tests/PhpGenerator/PhpFile.phpt index de282713..2b3be0a2 100644 --- a/tests/PhpGenerator/PhpFile.phpt +++ b/tests/PhpGenerator/PhpFile.phpt @@ -119,3 +119,9 @@ $file->setStrictTypes(); $file->addClass('A'); sameFile(__DIR__ . '/expected/PhpFile.strictTypes.expect', (string) $file); + + + +$file = PhpFile::fromCode(file_get_contents(__DIR__ . '/fixtures/classes.php')); +Assert::type(PhpFile::class, $file); +sameFile(__DIR__ . '/expected/Factory.fromCode.expect', (string) $file); diff --git a/tests/PhpGenerator/expected/Factory.fromCode.74.expect b/tests/PhpGenerator/expected/Factory.fromCode.74.expect new file mode 100644 index 00000000..eaa4519b --- /dev/null +++ b/tests/PhpGenerator/expected/Factory.fromCode.74.expect @@ -0,0 +1,11 @@ +methods[$member->getName()] = $member; + */ + throw new \Nette\InvalidArgumentException('Argument must be Method|Property|Constant.'); + } + + + public function complex() + { + echo 1; + // single line comment + + // spaces - indent + // spaces - indent + + /* multi + line + comment */ + if ( + $a + && $b + $c) + {} + + /** multi + line + comment */ + // Alias Method will not be resolved in comment + if ($member instanceof \Abc\Method) { + $s1 = "\na\n\tb\n\t\tc\n"; + $s2 = "\na\n\t{$b}\n\t\t$c\n"; + + $s3 = "a\n\t{$b}\n\t\t$c" + ; + $s3 = "a\n\tb\n\t\tc" + ; + // inline HTML is not supported + ?> + a + b + c +