diff --git a/composer.json b/composer.json index 6d3413999f..eb21a45682 100644 --- a/composer.json +++ b/composer.json @@ -22,9 +22,8 @@ "league/mime-type-detection": "^1.16", "league/oauth2-client": "^2.8", "monolog/monolog": "^3.7.0", - "nette/php-generator": "4.2.3", "nette/schema": "^1.3.4", - "nikic/php-parser": "^5.3", + "nikic/php-parser": "^5.9", "php": "^8.5", "psr-discovery/http-client-implementations": "^1.4", "psr-discovery/http-factory-implementations": "^1.2", diff --git a/docs/5-extra-topics/01-package-development.md b/docs/5-extra-topics/01-package-development.md index 7236c10e36..e452322011 100644 --- a/docs/5-extra-topics/01-package-development.md +++ b/docs/5-extra-topics/01-package-development.md @@ -71,7 +71,6 @@ The user will have a chance to specify the destination of the file, and whether use Tempest\Core\Installer; use Tempest\Core\PublishesFiles; use Tempest\Discovery\SkipDiscovery; -use Tempest\Generation\ClassManipulator; use function Tempest\src_namespace; use function Tempest\src_path; diff --git a/packages/generation/composer.json b/packages/generation/composer.json index 28e864ca30..783a7c4451 100644 --- a/packages/generation/composer.json +++ b/packages/generation/composer.json @@ -5,9 +5,7 @@ "minimum-stability": "dev", "require": { "php": "^8.5", - "nette/php-generator": "4.2.3", - "nette/schema": "^1.3.4", - "nikic/php-parser": "^5.3", + "nikic/php-parser": "^5.9", "tempest/support": "3.x-dev", "psr/container": "^2.0" }, diff --git a/packages/generation/src/Php/Attributes.php b/packages/generation/src/Php/Attributes.php new file mode 100644 index 0000000000..cf7cd663cb --- /dev/null +++ b/packages/generation/src/Php/Attributes.php @@ -0,0 +1,51 @@ + $arguments + */ + public static function add(Stmt\Class_|Stmt\ClassMethod|Param $node, string $name, array $arguments = []): void + { + $node->attrGroups[] = new AttributeGroup([ + new Attribute(new Name\FullyQualified(ltrim($name, '\\')), ValueExporter::exportArguments($arguments)), + ]); + } + + public static function remove(Stmt\Class_|Stmt\ClassMethod|Param $node, string $name): void + { + $groups = []; + + foreach ($node->attrGroups as $group) { + $attributes = array_values(array_filter( + $group->attrs, + static fn (Attribute $attribute) => ! Names::refersTo($attribute->name, $name), + )); + + if ($attributes === []) { + continue; + } + + if (count($attributes) !== count($group->attrs)) { + $group->attrs = $attributes; + } + + $groups[] = $group; + } + + $node->attrGroups = $groups; + } +} diff --git a/packages/generation/src/Php/Changes.php b/packages/generation/src/Php/Changes.php new file mode 100644 index 0000000000..1eaa735ead --- /dev/null +++ b/packages/generation/src/Php/Changes.php @@ -0,0 +1,86 @@ + true, + Stmt\Use_::class => true, + Stmt\GroupUse::class => true, + Stmt\Class_::class => true, + Stmt\ClassMethod::class => true, + Stmt\Declare_::class => true, + Node\DeclareItem::class => true, + Node\AttributeGroup::class => true, + Node\Param::class => true, + Node\UseItem::class => true, + ]; + + /** + * Marks a node as changed, returning whether it wasn't marked yet. + */ + public static function mark(Node $node): bool + { + if ($node->getAttribute(self::CHANGED_ATTRIBUTE) === true) { + return false; + } + + $node->setAttribute(self::CHANGED_ATTRIBUTE, true); + + return true; + } + + public static function isUnchanged(Node $node): bool + { + $original = $node->getAttribute('origNode'); + + if (! $original instanceof Node || $node->getAttribute(self::CHANGED_ATTRIBUTE) === true) { + return false; + } + + if (! isset(self::MUTABLE_NODES[$node::class])) { + return true; + } + + if ($node->getComments() !== $original->getComments()) { + return false; + } + + return array_all($node->getSubNodeNames(), static fn (string $name) => self::isSame($node->{$name}, $original->{$name})); + } + + private static function isSame(mixed $value, mixed $original): bool + { + if ($value instanceof Node) { + return $value->getAttribute('origNode') === $original && self::isUnchanged($value); + } + + if (! is_array($value)) { + return $value === $original; + } + + if (! is_array($original) || count($value) !== count($original)) { + return false; + } + + return array_all($value, static fn (mixed $item, int|string $key) => array_key_exists($key, $original) && self::isSame($item, $original[$key])); + } +} diff --git a/packages/generation/src/Php/ClassGenerator.php b/packages/generation/src/Php/ClassGenerator.php index 848341ab05..c18e8d9ea7 100644 --- a/packages/generation/src/Php/ClassGenerator.php +++ b/packages/generation/src/Php/ClassGenerator.php @@ -4,17 +4,13 @@ namespace Tempest\Generation\Php; -use Nette\PhpGenerator\ClassType; -use Nette\PhpGenerator\PhpFile; - final class ClassGenerator { use ManipulatesPhpClasses; public function __construct(string $name, string $namespace) { - $this->file = new PhpFile(); - $this->classType = new ClassType($name); - $this->namespace = $namespace; + $this->file = PhpFile::create($namespace); + $this->class = $this->file->addClass($name); } } diff --git a/packages/generation/src/Php/ClassManipulator.php b/packages/generation/src/Php/ClassManipulator.php index b227a794f5..d2c0f72614 100644 --- a/packages/generation/src/Php/ClassManipulator.php +++ b/packages/generation/src/Php/ClassManipulator.php @@ -4,8 +4,6 @@ namespace Tempest\Generation\Php; -use Nette\PhpGenerator\ClassType; -use Nette\PhpGenerator\PhpFile; use ReflectionClass; use Tempest\Generation\Php\Exceptions\FileGenerationFailedException; use Tempest\Support\Filesystem; @@ -15,18 +13,29 @@ final class ClassManipulator { use ManipulatesPhpClasses; - public function __construct(string|ReflectionClass $source) + /** + * @param string|class-string|ReflectionClass|PhpFile $source a file path, a class name, or the loaded file + */ + public function __construct(string|ReflectionClass|PhpFile $source) { - if (is_file($source)) { - $this->classType = ClassType::fromCode(Filesystem\read_file($source)); - } elseif (is_string($source)) { - $this->classType = ClassType::from($source, withBodies: true); - } else { - $this->classType = ClassType::from($source->getName(), withBodies: true); + if ($source instanceof PhpFile) { + $this->file = $source; + $this->class = $source->getClass(); + + return; + } + + if (is_string($source) && is_file($source)) { + $this->file = PhpFile::fromPath($source); + $this->class = $this->file->getClass(); + + return; } - $this->file = new PhpFile(); - $this->namespace = $this->classType->getNamespace()->getName(); + $reflection = is_string($source) ? new ReflectionClass($source) : $source; + + $this->file = PhpFile::fromPath($reflection->getFileName()); + $this->class = $this->file->getClass($reflection->getShortName()); } /** @@ -38,13 +47,8 @@ public function __construct(string|ReflectionClass $source) */ public function save(string $path): self { - $dir = pathinfo($path, PATHINFO_DIRNAME); - - if (! is_dir($dir)) { - mkdir($dir, recursive: true); - } - try { + Filesystem\create_directory_for_file($path); Filesystem\write_file($path, $this->print()); } catch (FilesystemException) { throw new FileGenerationFailedException(sprintf('The file "%s" could not be written.', $path)); diff --git a/packages/generation/src/Php/Cloner.php b/packages/generation/src/Php/Cloner.php new file mode 100644 index 0000000000..a61f8e8c39 --- /dev/null +++ b/packages/generation/src/Php/Cloner.php @@ -0,0 +1,167 @@ + keys of the imports that names rely on */ + public array $importKeys = []; + + private ?string $namespace = null; + + /** @var array> imported names by type and alias */ + private array $imports = []; + + /** + * @param Stmt[] $stmts + * @return Stmt[] + */ + public function cloneStatements(array $stmts): array + { + /** @var Stmt[] */ + return $this->cloneValue($stmts, Stmt\Use_::TYPE_NORMAL); + } + + private function cloneValue(mixed $value, int $nameType): mixed + { + if ($value instanceof Node) { + return $this->cloneNode($value, $nameType); + } + + if (! is_array($value)) { + return $value; + } + + foreach ($value as $key => $item) { + $value[$key] = $this->cloneValue($item, $nameType); + } + + return $value; + } + + private function cloneNode(Node $node, int $nameType): Node + { + $clone = clone $node; + $clone->setAttribute('origNode', $node); + + if ($clone instanceof Name) { + $this->resolve($clone, $nameType); + + return $clone; + } + + if ($node instanceof Stmt\Namespace_) { + $this->namespace = $node->name?->toString(); + $this->imports = []; + } elseif ($node instanceof Stmt\Use_ || $node instanceof Stmt\GroupUse) { + $this->import($node); + } + + $nameType = $this->nameTypeOf($node); + + foreach ($node->getSubNodeNames() as $subNodeName) { + $clone->{$subNodeName} = $this->cloneValue($node->{$subNodeName}, $nameType); + } + + return $clone; + } + + /** + * Returns the type of the names that are direct children of a node. + */ + private function nameTypeOf(Node $node): int + { + return match (true) { + $node instanceof Stmt\Namespace_, $node instanceof Stmt\Use_, $node instanceof Stmt\GroupUse, $node instanceof Node\UseItem => self::UNRESOLVED, + $node instanceof Expr\FuncCall => Stmt\Use_::TYPE_FUNCTION, + $node instanceof Expr\ConstFetch => Stmt\Use_::TYPE_CONSTANT, + default => Stmt\Use_::TYPE_NORMAL, + }; + } + + private function import(Stmt\Use_|Stmt\GroupUse $node): void + { + foreach ($node->uses as $item) { + $type = $node->type !== Stmt\Use_::TYPE_UNKNOWN ? $node->type : $item->type; + $name = $node instanceof Stmt\GroupUse ? Name::concat($node->prefix, $item->name) : $item->name; + + $this->imports[$type][self::aliasKey($type, $item->getAlias()->toString())] = $name->toString(); + } + } + + private function resolve(Name $name, int $type): void + { + if ($type === self::UNRESOLVED) { + return; + } + + $importKey = Imports::keyOf($name, $type); + + if ($importKey !== null) { + $this->importKeys[$importKey] = true; + } + + if ($name->isFullyQualified() || $name->isSpecialClassName()) { + return; + } + + if ($name->isRelative()) { + $resolved = $this->prefixNamespace($name->toString()); + } elseif ($type !== Stmt\Use_::TYPE_NORMAL && $name->isUnqualified()) { + $resolved = $this->imports[$type][self::aliasKey($type, $name->toString())] ?? $this->resolveGlobally($name, $type); + } else { + $import = $this->imports[Stmt\Use_::TYPE_NORMAL][strtolower($name->getFirst())] ?? null; + $resolved = match (true) { + $import === null => $this->prefixNamespace($name->toString()), + $name->isQualified() => $import . '\\' . $name->slice(1)?->toString(), + default => $import, + }; + } + + if ($resolved !== null) { + $name->setAttribute(Names::RESOLVED_NAME_ATTRIBUTE, new Name\FullyQualified($resolved)); + } + } + + /** + * Unqualified functions and constants only refer to a known definition outside of a namespace, + * since PHP falls back to the global one inside a namespace. + */ + private function resolveGlobally(Name $name, int $type): ?string + { + if ($this->namespace !== null) { + return null; + } + + if ($type === Stmt\Use_::TYPE_CONSTANT && in_array($name->toLowerString(), ['true', 'false', 'null'], strict: true)) { + return null; + } + + return $name->toString(); + } + + private function prefixNamespace(string $name): string + { + return $this->namespace === null ? $name : $this->namespace . '\\' . $name; + } + + private static function aliasKey(int $type, string $alias): string + { + return $type === Stmt\Use_::TYPE_CONSTANT ? $alias : strtolower($alias); + } +} diff --git a/packages/generation/src/Php/DataObjects/StubFile.php b/packages/generation/src/Php/DataObjects/StubFile.php index f0b57fe640..c391aa4406 100644 --- a/packages/generation/src/Php/DataObjects/StubFile.php +++ b/packages/generation/src/Php/DataObjects/StubFile.php @@ -5,10 +5,12 @@ namespace Tempest\Generation\Php\DataObjects; use InvalidArgumentException; -use Nette\InvalidStateException; -use ReflectionException; -use Tempest\Generation\Php\ClassManipulator; +use PhpParser\Node\Stmt; +use ReflectionClass; +use Tempest\Generation\Php\Exceptions\PhpCodeWasInvalid; +use Tempest\Generation\Php\SourceParser; use Tempest\Generation\Php\StubFileType; +use Tempest\Support\Filesystem; /** * Represents a file that is to be generated. @@ -25,22 +27,45 @@ public function __construct( */ public static function from(string $pathOrClass): self { - try { - new ClassManipulator($pathOrClass); - + if (class_exists($pathOrClass)) { return new self( - filePath: $pathOrClass, + filePath: new ReflectionClass($pathOrClass)->getFileName(), type: StubFileType::CLASS_FILE, ); - } catch (InvalidStateException|ReflectionException) { - if (! file_exists($pathOrClass)) { - throw new InvalidArgumentException(sprintf('The file "%s" does not exist.', $pathOrClass)); + } + + if (! is_file($pathOrClass)) { + throw new InvalidArgumentException(sprintf('The file "%s" does not exist.', $pathOrClass)); + } + + return new self( + filePath: $pathOrClass, + type: self::declaresClass($pathOrClass) ? StubFileType::CLASS_FILE : StubFileType::RAW_FILE, + ); + } + + /** + * Whether the file is valid PHP that declares a class where `PhpFile::getClass()` finds it: + * at the top level, or in its first namespace. + */ + private static function declaresClass(string $path): bool + { + try { + [$stmts] = SourceParser::parse(Filesystem\read_file($path)); + } catch (PhpCodeWasInvalid) { + return false; + } + + foreach ($stmts as $stmt) { + if (! $stmt instanceof Stmt\Namespace_) { + continue; } - return new self( - filePath: $pathOrClass, - type: StubFileType::RAW_FILE, - ); + $stmts = $stmt->stmts; + + break; } + + return array_any($stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\Class_); } } diff --git a/packages/generation/src/Php/DocComment.php b/packages/generation/src/Php/DocComment.php new file mode 100644 index 0000000000..7aac8afaf1 --- /dev/null +++ b/packages/generation/src/Php/DocComment.php @@ -0,0 +1,23 @@ + rtrim(' * ' . $line), + explode("\n", trim($text)), + ); + + return new Doc("/**\n" . implode("\n", $lines) . "\n */"); + } +} diff --git a/packages/generation/src/Php/Exceptions/ClassWasMissing.php b/packages/generation/src/Php/Exceptions/ClassWasMissing.php new file mode 100644 index 0000000000..c9a6c66c32 --- /dev/null +++ b/packages/generation/src/Php/Exceptions/ClassWasMissing.php @@ -0,0 +1,18 @@ + 'The PHP code does not declare a class.', + default => sprintf('The PHP code does not declare a class named "%s".', $name), + }); + } +} diff --git a/packages/generation/src/Php/Exceptions/MethodWasMissing.php b/packages/generation/src/Php/Exceptions/MethodWasMissing.php new file mode 100644 index 0000000000..cc59ccfd2e --- /dev/null +++ b/packages/generation/src/Php/Exceptions/MethodWasMissing.php @@ -0,0 +1,15 @@ +getMessage()), previous: $previous); + } +} diff --git a/packages/generation/src/Php/Exceptions/ValueWasNotExportable.php b/packages/generation/src/Php/Exceptions/ValueWasNotExportable.php new file mode 100644 index 0000000000..b5ddb86ed0 --- /dev/null +++ b/packages/generation/src/Php/Exceptions/ValueWasNotExportable.php @@ -0,0 +1,15 @@ + import key to fully qualified name */ + private array $names = []; + + /** @var array lowercased type and fully qualified name to alias */ + private array $aliases = []; + + /** @var array */ + private array $pending = []; + + /** + * @param Stmt[] $stmts + */ + public static function from(array $stmts): self + { + $imports = new self(); + + foreach ($stmts as $stmt) { + if ($stmt instanceof Stmt\Use_) { + foreach ($stmt->uses as $item) { + $imports->register($stmt->type, $item->name->toString(), $item->getAlias()->toString()); + } + } elseif ($stmt instanceof Stmt\GroupUse) { + foreach ($stmt->uses as $item) { + $type = $stmt->type !== Stmt\Use_::TYPE_UNKNOWN ? $stmt->type : $item->type; + $imports->register($type, Name::concat($stmt->prefix, $item->name)->toString(), $item->getAlias()->toString()); + } + } + } + + return $imports; + } + + public static function key(int $type, string $alias): string + { + return $type . ':' . strtolower($alias); + } + + /** + * Returns the key of the import a name relies on, if any. + */ + public static function keyOf(Name $name, int $type): ?string + { + if ($name->isFullyQualified() || $name->isRelative() || $name->isSpecialClassName()) { + return null; + } + + if ($name->isQualified()) { + return self::key(Stmt\Use_::TYPE_NORMAL, $name->getFirst()); + } + + return self::key($type, $name->getFirst()); + } + + public function resolve(int $type, string $alias): ?string + { + return $this->names[self::key($type, $alias)] ?? null; + } + + public function aliasOf(int $type, string $name): ?string + { + return $this->aliases[self::key($type, $name)] ?? null; + } + + /** + * Resolves a class name, or a qualified function or constant name, as it would be in the given namespace. + */ + public function resolveClassName(Name $name, ?string $namespace): string + { + if ($name->isFullyQualified()) { + return $name->toString(); + } + + if (! $name->isRelative()) { + $import = $this->resolve(Stmt\Use_::TYPE_NORMAL, $name->getFirst()); + + if ($import !== null) { + return $name->isQualified() ? $import . '\\' . $name->slice(1)?->toString() : $import; + } + } + + return ltrim($namespace . '\\' . $name->toString(), '\\'); + } + + /** + * @param Stmt\Use_::TYPE_* $type + */ + public function add(int $type, string $name, ?string $alias = null): void + { + $alias ??= Names::lastSegment($name); + + $this->register($type, $name, $alias); + $this->pending[self::key($type, $name)] = [$type, $name, $alias]; + } + + /** + * Inserts the imports that were added into the given statements. + * + * @param Stmt[] $stmts + */ + public function insertInto(array &$stmts): void + { + $pending = $this->pending; + $this->pending = []; + + uasort($pending, static fn (array $a, array $b) => strcasecmp($a[1], $b[1])); + + foreach ($pending as [$type, $name, $alias]) { + $item = new UseItem(new Name($name), $alias !== Names::lastSegment($name) ? new Identifier($alias) : null); + + if ($this->updateExistingImport($stmts, $type, $name, $item)) { + continue; + } + + self::insertImport($stmts, $type, new Stmt\Use_([$item], $type)); + } + } + + /** + * @param Stmt[] $stmts + * @param array $used + */ + public static function hasUnused(array $stmts, array $used): bool + { + foreach ($stmts as $stmt) { + if (! $stmt instanceof Stmt\Use_) { + continue; + } + + if (array_any($stmt->uses, static fn (UseItem $item) => ! isset($used[self::key($stmt->type, $item->getAlias()->toString())]))) { + return true; + } + } + + return false; + } + + /** + * Removes the imports that were relied on before, but no longer are. + * Imports that are mentioned in comments are kept, since doc comments may refer to them. + * + * @param Stmt[] $stmts + * @param array $previouslyUsed + * @param array $used + */ + public static function removeUnused(array &$stmts, array $previouslyUsed, array $used, string $comments): void + { + foreach ($stmts as $index => $stmt) { + if (! $stmt instanceof Stmt\Use_) { + continue; + } + + $items = array_values(array_filter( + $stmt->uses, + static function (UseItem $item) use ($stmt, $previouslyUsed, $used, $comments): bool { + $alias = $item->getAlias()->toString(); + $key = self::key($stmt->type, $alias); + + if (! isset($previouslyUsed[$key]) || isset($used[$key])) { + return true; + } + + return preg_match('/(?uses)) { + $stmt->uses = $items; + } + } + + $stmts = array_values($stmts); + } + + private function register(int $type, string $name, string $alias): void + { + $this->names[self::key($type, $alias)] = $name; + $this->aliases[self::key($type, $name)] = $alias; + } + + /** + * @param Stmt[] $stmts + */ + private function updateExistingImport(array $stmts, int $type, string $name, UseItem $replacement): bool + { + foreach ($stmts as $stmt) { + $items = match (true) { + $stmt instanceof Stmt\Use_ && $stmt->type === $type => $stmt->uses, + $stmt instanceof Stmt\GroupUse => array_filter( + $stmt->uses, + static fn (UseItem $item) => ($stmt->type !== Stmt\Use_::TYPE_UNKNOWN ? $stmt->type : $item->type) === $type, + ), + default => [], + }; + + foreach ($items as $item) { + $itemName = $stmt instanceof Stmt\GroupUse ? Name::concat($stmt->prefix, $item->name) : $item->name; + + if (strcasecmp($itemName->toString(), $name) !== 0) { + continue; + } + + if ($item->getAlias()->toString() !== $replacement->getAlias()->toString()) { + $item->alias = $replacement->alias; + } + + return true; + } + } + + return false; + } + + /** + * @param Stmt[] $stmts + */ + private static function insertImport(array &$stmts, int $type, Stmt\Use_ $import): void + { + $name = $import->uses[0]->name->toString(); + $lastImportBefore = null; + $lastImportType = null; + + foreach ($stmts as $index => $stmt) { + if (! $stmt instanceof Stmt\Use_ && ! $stmt instanceof Stmt\GroupUse && ! $stmt instanceof Stmt\Declare_ && ! Printer::isBlankLine($stmt)) { + break; + } + + if (! $stmt instanceof Stmt\Use_) { + continue; + } + + if ($stmt->type === $type) { + if (strcasecmp($stmt->uses[0]->name->toString(), $name) > 0) { + array_splice($stmts, $index, 0, [$import]); + + return; + } + + $lastImportBefore = $index; + $lastImportType = $stmt->type; + } elseif ($stmt->type < $type) { + $lastImportBefore = $index; + $lastImportType = $stmt->type; + } elseif ($lastImportBefore === null || $lastImportType !== $type) { + $position = $lastImportBefore !== null ? $lastImportBefore + 1 : $index; + $replacement = $lastImportBefore !== null ? [Printer::blankLine(), $import] : [$import, Printer::blankLine()]; + + array_splice($stmts, $position, 0, $replacement); + + return; + } else { + array_splice($stmts, $lastImportBefore + 1, 0, [$import]); + + return; + } + } + + if ($lastImportBefore !== null) { + array_splice($stmts, $lastImportBefore + 1, 0, $lastImportType === $type ? [$import] : [Printer::blankLine(), $import]); + + return; + } + + $position = 0; + + while (isset($stmts[$position]) && $stmts[$position] instanceof Stmt\Declare_) { + $position++; + } + + array_splice($stmts, $position, 0, $stmts === [] ? [$import] : [$import, Printer::blankLine()]); + } +} diff --git a/packages/generation/src/Php/Literal.php b/packages/generation/src/Php/Literal.php new file mode 100644 index 0000000000..438c6e7589 --- /dev/null +++ b/packages/generation/src/Php/Literal.php @@ -0,0 +1,15 @@ + */ - protected array $manipulations = []; + protected PhpClass $class; + /** + * @param array $parameters parameter names mapped to their types + */ public function addMethod( string $name, string $body = '', array $parameters = [], string $returnType = 'void', ): self { - $method = $this->classType - ->addMethod($name, overwrite: true) + $method = $this->class + ->addMethod($name) ->setReturnType($returnType) ->setBody($body); foreach ($parameters as $parameter => $type) { - $method - ->addParameter($parameter) - ->setType($type); + $method->addParameter($parameter, $type); } return $this; } - public function manipulate(Closure $callback): self - { - $this->manipulations[] = $callback; - - return $this; - } - public function setClassName(string $name): self { - $this->classType->setName($name); + $this->class->setName($name); return $this; } public function removeMethod(string $name): self { - $this->classType->removeMethod($name); + $this->class->removeMethod($name); return $this; } + /** + * @param array $arguments + */ public function addMethodAttribute(string $method, string $attribute, array $arguments = []): self { - $method = $this->classType->getMethod($method); - $method->addAttribute($attribute, $arguments); + $this->class->getMethod($method)->addAttribute($attribute, $arguments); return $this; } + /** + * @param array $arguments + */ public function addClassAttribute(string $attributeName, array $arguments = []): self { - $this->classType->addAttribute($attributeName, $arguments); + $this->class->addAttribute($attributeName, $arguments); return $this; } public function removeClassAttribute(string $attributeName): self { - $attributes = $this->classType->getAttributes(); - - foreach ($attributes as $key => $attribute) { - if ($attribute->getName() !== $attributeName) { - continue; - } - - unset($attributes[$key]); - } - - $this->classType->setAttributes($attributes); + $this->class->removeAttribute($attributeName); return $this; } @@ -110,21 +86,21 @@ public function setStrictTypes(bool $strictTypes = true): self public function setFinal(bool $final = true): self { - $this->classType->setFinal($final); + $this->class->setFinal($final); return $this; } public function setReadOnly(bool $readonly = true): self { - $this->classType->setReadOnly($readonly); + $this->class->setReadOnly($readonly); return $this; } public function setAbstract(bool $abstract = true): self { - $this->classType->setAbstract($abstract); + $this->class->setAbstract($abstract); return $this; } @@ -138,67 +114,78 @@ public function setFileComment(?string $comment): self public function setClassComment(?string $comment): self { - $this->classType->setComment($comment); + $this->class->setComment($comment); return $this; } public function addImplement(string $interface): self { - $this->classType->addImplement($interface); + $this->class->addImplement($interface); return $this; } public function addExtend(string $class): self { - $this->classType->setExtends($class); + $this->class->setExtends($class); return $this; } public function addTrait(string $trait): self { - $this->classType->addTrait($trait); + $this->class->addTrait($trait); return $this; } public function setNamespace(string $namespace): self { - $this->namespace = $namespace; + $this->file->setNamespace($namespace); return $this; } - public function getClassName(): string + public function simplifyClassNamesInMethodBodies(bool $simplify = true): self { - return $this->namespace . '\\' . $this->classType->getName(); + $this->file->simplifyNamesInBodies($simplify); + + return $this; } - public function print(): string + public function simplifyImplements(bool $simplify = true): self { - if (! $this->namespace) { - throw new GeneratedClassWasMissingANamespace(); - } + $this->file->simplifyImplements($simplify); - $printer = new PsrPrinter(); + return $this; + } - $file = clone $this->file; - $namespace = $file->addNamespace($this->namespace); - $namespace->add($this->classType); + public function addUse(string $use): self + { + $this->file->addUse($use); - $file = $this->simplifyClassNames($file); - $code = $printer->printFile($file); + return $this; + } - foreach ($this->manipulations as $manipulation) { - $code = (string) $manipulation(str($code)); - } + public function setAlias(string $class, string $alias): self + { + $this->file->addUse($class, $alias); + + return $this; + } + + public function getClassName(): string + { + return ltrim($this->file->getNamespace() . '\\' . $this->class->getName(), '\\'); + } - $updatedFile = $file->fromCode($code); - $updatedFile->setComment($file->getComment()); - $updatedFile = $this->simplifyClassNames($updatedFile); + public function print(): string + { + if (! $this->file->getNamespace()) { + throw new GeneratedClassWasMissingANamespace(); + } - return $printer->printFile($updatedFile); + return $this->file->print(); } } diff --git a/packages/generation/src/Php/MethodBody.php b/packages/generation/src/Php/MethodBody.php new file mode 100644 index 0000000000..a3dfb3fb16 --- /dev/null +++ b/packages/generation/src/Php/MethodBody.php @@ -0,0 +1,170 @@ + replacements keyed by their offset, with the replaced length and code */ + private array $replacements = []; + + /** + * @param array $verbatimLines lines whose indentation is part of a string's value + */ + private function __construct( + private readonly string $code, + private readonly int $indentation, + private readonly array $verbatimLines, + private readonly string $comments, + ) {} + + /** + * @return array{self, Stmt[]} + */ + public static function parse(string $code): array + { + $code = trim($code, "\n"); + + [$stmts, $tokens] = SourceParser::parse(self::OPEN_TAG . $code); + + $lines = explode("\n", $code); + $verbatimLines = self::verbatimLines($stmts, $lines); + $comments = ''; + + foreach ($tokens as $token) { + if (! $token->is([T_COMMENT, T_DOC_COMMENT])) { + continue; + } + + $comments .= $token->text . "\n"; + } + + return [new self($code, self::indentationOf($lines, $verbatimLines), $verbatimLines, $comments), $stmts]; + } + + /** + * Replaces a name that was parsed from this body. + */ + public function replaceName(Name $name, Name $replacement): void + { + $start = $name->getStartFilePos(); + + if ($start < 0) { + return; + } + + $this->replacements[$start - strlen(self::OPEN_TAG)] = [$name->getEndFilePos() - $start + 1, $replacement->toCodeString()]; + } + + public function comments(): string + { + return $this->comments; + } + + /** + * Prints the body with its own indentation replaced by the given one. + */ + public function print(string $indentation): string + { + $code = $this->code; + $replacements = $this->replacements; + + krsort($replacements); + + foreach ($replacements as $offset => [$length, $replacement]) { + $code = substr_replace($code, $replacement, $offset, $length); + } + + $lines = explode("\n", $code); + + foreach ($lines as $index => $line) { + if ($line === '' || isset($this->verbatimLines[$index])) { + continue; + } + + $lines[$index] = $indentation . substr($line, min($this->indentation, strspn($line, " \t"))); + } + + return implode("\n", $lines); + } + + /** + * Finds the lines whose indentation is part of a string's value. Heredocs may be reindented as a whole, + * as long as they are indented with spaces only, since PHP strips the indentation of their closing marker. + * + * @param Stmt[] $stmts + * @param string[] $lines + * @return array + */ + private static function verbatimLines(array $stmts, array $lines): array + { + $strings = new NodeFinder()->find( + $stmts, + static fn (Node $node) => ( + ($node instanceof Scalar\String_ || $node instanceof Scalar\InterpolatedString || $node instanceof Expr\ShellExec) + && $node->getStartLine() !== $node->getEndLine() + ), + ); + + $verbatimLines = []; + + foreach ($strings as $string) { + // The parser counts the opening tag as the first line, and a string's first line keeps its indentation. + $stringLines = range($string->getStartLine() - 1, $string->getEndLine() - 2); + $isHeredoc = in_array($string->getAttribute('kind'), [Scalar\String_::KIND_HEREDOC, Scalar\String_::KIND_NOWDOC], strict: true); + + if ($isHeredoc && ! self::hasTabIndentation(array_intersect_key($lines, array_flip($stringLines)))) { + continue; + } + + $verbatimLines += array_fill_keys($stringLines, true); + } + + return $verbatimLines; + } + + /** + * @param string[] $lines + * @param array $verbatimLines + */ + private static function indentationOf(array $lines, array $verbatimLines): int + { + $indentation = PHP_INT_MAX; + + foreach ($lines as $index => $line) { + if (isset($verbatimLines[$index]) || trim($line) === '') { + continue; + } + + $indentation = min($indentation, strspn($line, " \t")); + } + + return $indentation === PHP_INT_MAX ? 0 : $indentation; + } + + /** + * @param string[] $lines + */ + private static function hasTabIndentation(array $lines): bool + { + return array_any($lines, static fn (string $line) => str_contains(substr($line, 0, strspn($line, " \t")), "\t")); + } +} diff --git a/packages/generation/src/Php/NameSimplifier.php b/packages/generation/src/Php/NameSimplifier.php new file mode 100644 index 0000000000..78fdb445e1 --- /dev/null +++ b/packages/generation/src/Php/NameSimplifier.php @@ -0,0 +1,321 @@ + */ + public array $usedImports = []; + + /** @var array short names that refer to a class without an import, mapped to that class */ + private array $reservedClasses = []; + + /** @var array short names that refer to a function without an import */ + private array $reservedFunctions = []; + + /** @var list */ + private array $names = []; + + /** @var array */ + private array $parents = []; + + /** + * @param array $declaredClasses lowercased names of the classes declared in the namespace + */ + public function __construct( + private readonly ?string $namespace, + private readonly Imports $imports, + private readonly array $declaredClasses, + private readonly bool $simplifyImplements, + private readonly bool $simplifyBodies, + ) {} + + /** + * @param Stmt[] $stmts + */ + public function simplify(array $stmts): void + { + foreach (array_keys($this->declaredClasses) as $class) { + $this->reservedClasses[$class] = ltrim($this->namespace . '\\' . $class, '\\'); + } + + foreach ($stmts as $stmt) { + $this->visit($stmt, null, Stmt\Use_::TYPE_NORMAL, inBody: false, inImplements: false, methodBody: null); + } + + foreach ($this->names as [$name, $type, $inBody, $inImplements, $methodBody]) { + $simplified = match ($type) { + Stmt\Use_::TYPE_NORMAL => $this->simplifyClassName($name, $inBody, $inImplements), + Stmt\Use_::TYPE_FUNCTION => $this->simplifyFunctionName($name, $inBody), + default => $this->qualifyBrokenName($name, $type), + }; + + if ($simplified !== null) { + $this->replace($name, $simplified); + $methodBody?->replaceName($name, $simplified); + } + + $key = Imports::keyOf($simplified ?? $name, $type); + + if ($key !== null) { + $this->usedImports[$key] = true; + } + } + } + + /** + * Collects every name along with the context it is used in. Import statements are skipped. + * + * @param int $nameType The `Stmt\Use_::TYPE_*` of a name visited directly, or `IGNORED`. + */ + private function visit(Node $node, ?Node $parent, int $nameType, bool $inBody, bool $inImplements, ?MethodBody $methodBody): void + { + if ($parent !== null) { + $this->parents[spl_object_id($node)] = $parent; + } + + if ($node instanceof Name) { + if ($nameType !== self::IGNORED) { + $this->reserve($node, $nameType); + $this->names[] = [$node, $nameType, $inBody, $inImplements, $methodBody]; + } + + return; + } + + if ($node instanceof Stmt\Use_ || $node instanceof Stmt\GroupUse) { + return; + } + + if ($node instanceof Stmt\Function_) { + $this->reservedFunctions[$node->name->toLowerString()] = true; + } + + $childNameType = match (true) { + $node instanceof Stmt\Namespace_ => self::IGNORED, + $node instanceof Expr\FuncCall => Stmt\Use_::TYPE_FUNCTION, + $node instanceof Expr\ConstFetch => Stmt\Use_::TYPE_CONSTANT, + default => Stmt\Use_::TYPE_NORMAL, + }; + $hasBody = $node instanceof Stmt\ClassMethod || $node instanceof Stmt\Function_; + $bodyMethodBody = $node instanceof Stmt\ClassMethod ? $node->getAttribute(MethodBody::ATTRIBUTE) ?? $methodBody : $methodBody; + + foreach ($node->getSubNodeNames() as $subNodeName) { + $value = $node->{$subNodeName}; + $isBody = $hasBody && $subNodeName === 'stmts'; + $childInBody = $inBody || $isBody; + $childMethodBody = $isBody ? $bodyMethodBody : $methodBody; + $childInImplements = $node instanceof Stmt\Class_ && $subNodeName === 'implements'; + + foreach (is_array($value) ? $value : [$value] as $child) { + if (! $child instanceof Node) { + continue; + } + + $this->visit($child, $node, $childNameType, $childInBody, $childInImplements, $childMethodBody); + } + } + } + + /** + * Remembers the short names that already refer to something without being imported. + */ + private function reserve(Name $name, int $type): void + { + if ($name->isFullyQualified() || $name->isRelative() || $name->isSpecialClassName()) { + return; + } + + $first = $name->getFirst(); + + if ($type !== Stmt\Use_::TYPE_NORMAL && $name->isUnqualified()) { + if ($type === Stmt\Use_::TYPE_FUNCTION && $this->imports->resolve($type, $first) === null) { + $this->reservedFunctions[strtolower($first)] = true; + } + + return; + } + + if ($this->imports->resolve(Stmt\Use_::TYPE_NORMAL, $first) !== null) { + return; + } + + $resolved = Names::resolve($name); + + if ($resolved !== null && strcasecmp($resolved, $this->imports->resolveClassName($name, $this->namespace)) !== 0) { + return; + } + + $this->reservedClasses[strtolower($first)] = ltrim($this->namespace . '\\' . $first, '\\'); + } + + private function replace(Name $name, Name $replacement): void + { + $replacement->setAttribute('startFilePos', $name->getStartFilePos()); + $replacement->setAttribute('endFilePos', $name->getEndFilePos()); + + $parent = $this->parents[spl_object_id($name)] ?? null; + + if ($parent === null) { + return; + } + + foreach ($parent->getSubNodeNames() as $subNodeName) { + $value = $parent->{$subNodeName}; + + if ($value === $name) { + $parent->{$subNodeName} = $replacement; + + break; + } + + if (is_array($value) && ($key = array_search($name, $value, strict: true)) !== false) { + $value[$key] = $replacement; + $parent->{$subNodeName} = $value; + + break; + } + } + + $node = $parent; + + while ($node !== null && Changes::mark($node)) { + $node = $this->parents[spl_object_id($node)] ?? null; + } + } + + private function simplifyClassName(Name $name, bool $inBody, bool $inImplements): ?Name + { + if ($name->isSpecialClassName()) { + return null; + } + + $fullyQualifiedName = Names::resolve($name); + + if ($fullyQualifiedName === null) { + return null; + } + + $resolvesCorrectly = strcasecmp($this->imports->resolveClassName($name, $this->namespace), $fullyQualifiedName) === 0; + $shouldSimplify = $name->isFullyQualified() + && match (true) { + $inBody => $this->simplifyBodies, + $inImplements => $this->simplifyImplements, + default => true, + }; + + if ($resolvesCorrectly && ! $shouldSimplify) { + return null; + } + + $alias = $this->importClass($fullyQualifiedName); + + if ($alias === null) { + return $name->isFullyQualified() ? null : new Name\FullyQualified($fullyQualifiedName); + } + + return new Name($alias, [Names::RESOLVED_NAME_ATTRIBUTE => new Name\FullyQualified($fullyQualifiedName)]); + } + + private function simplifyFunctionName(Name $name, bool $inBody): ?Name + { + if (! $name->isFullyQualified()) { + return $this->qualifyBrokenName($name, Stmt\Use_::TYPE_FUNCTION); + } + + if ($inBody && ! $this->simplifyBodies) { + return null; + } + + $fullyQualifiedName = $name->toString(); + + if (Names::namespaceOf($fullyQualifiedName) === '') { + return null; + } + + $alias = $this->imports->aliasOf(Stmt\Use_::TYPE_FUNCTION, $fullyQualifiedName); + + if ($alias === null) { + $alias = Names::lastSegment($fullyQualifiedName); + + if ($this->imports->resolve(Stmt\Use_::TYPE_FUNCTION, $alias) !== null || isset($this->reservedFunctions[strtolower($alias)])) { + return null; + } + + $this->imports->add(Stmt\Use_::TYPE_FUNCTION, $fullyQualifiedName); + } + + return new Name($alias); + } + + /** + * Fully qualifies a function or constant name that no longer refers to the same thing, for example after the namespace changed. + */ + private function qualifyBrokenName(Name $name, int $type): ?Name + { + $fullyQualifiedName = $name->isFullyQualified() ? null : Names::resolve($name); + + if ($fullyQualifiedName === null) { + return null; + } + + $resolvedName = $name->isUnqualified() + ? $this->imports->resolve($type, $name->toString()) + : $this->imports->resolveClassName($name, $this->namespace); + + if ($resolvedName !== null && strcasecmp($resolvedName, $fullyQualifiedName) === 0) { + return null; + } + + return new Name\FullyQualified($fullyQualifiedName); + } + + /** + * Returns the name a class can be referred to with, importing it when needed. + */ + private function importClass(string $fullyQualifiedName): ?string + { + $alias = $this->imports->aliasOf(Stmt\Use_::TYPE_NORMAL, $fullyQualifiedName); + + if ($alias !== null) { + return $alias; + } + + $shortName = Names::lastSegment($fullyQualifiedName); + + if ($this->imports->resolve(Stmt\Use_::TYPE_NORMAL, $shortName) !== null) { + return null; + } + + if (strcasecmp(Names::namespaceOf($fullyQualifiedName), $this->namespace ?? '') === 0) { + $this->reservedClasses[strtolower($shortName)] = $fullyQualifiedName; + + return $shortName; + } + + $reservedClass = $this->reservedClasses[strtolower($shortName)] ?? null; + + if ($reservedClass !== null && strcasecmp($reservedClass, $fullyQualifiedName) !== 0) { + return null; + } + + $this->imports->add(Stmt\Use_::TYPE_NORMAL, $fullyQualifiedName); + + return $shortName; + } +} diff --git a/packages/generation/src/Php/Names.php b/packages/generation/src/Php/Names.php new file mode 100644 index 0000000000..6bc848ee55 --- /dev/null +++ b/packages/generation/src/Php/Names.php @@ -0,0 +1,48 @@ +isFullyQualified()) { + return $name->toString(); + } + + $resolved = $name->getAttribute(self::RESOLVED_NAME_ATTRIBUTE); + + return $resolved instanceof Name ? $resolved->toString() : null; + } + + public static function refersTo(Name $name, string $fullyQualifiedName): bool + { + return strcasecmp(self::resolve($name) ?? $name->toString(), ltrim($fullyQualifiedName, '\\')) === 0; + } + + public static function lastSegment(string $name): string + { + $position = strrpos($name, '\\'); + + return $position === false ? $name : substr($name, $position + 1); + } + + public static function namespaceOf(string $name): string + { + $position = strrpos($name, '\\'); + + return $position === false ? '' : substr($name, 0, $position); + } +} diff --git a/packages/generation/src/Php/PhpClass.php b/packages/generation/src/Php/PhpClass.php new file mode 100644 index 0000000000..2084cd91ed --- /dev/null +++ b/packages/generation/src/Php/PhpClass.php @@ -0,0 +1,171 @@ +node->name->toString(); + } + + public function setName(string $name): self + { + $this->node->name = new Identifier($name); + + return $this; + } + + public function setFinal(bool $final = true): self + { + return $this->setModifier(Modifiers::FINAL, $final); + } + + public function setReadOnly(bool $readonly = true): self + { + return $this->setModifier(Modifiers::READONLY, $readonly); + } + + public function setAbstract(bool $abstract = true): self + { + return $this->setModifier(Modifiers::ABSTRACT, $abstract); + } + + public function setExtends(?string $class): self + { + $this->node->extends = $class !== null ? new Name\FullyQualified(ltrim($class, '\\')) : null; + + return $this; + } + + public function addImplement(string $interface): self + { + if (array_any($this->node->implements, static fn (Name $implement) => Names::refersTo($implement, $interface))) { + return $this; + } + + $this->node->implements[] = new Name\FullyQualified(ltrim($interface, '\\')); + + return $this; + } + + public function addTrait(string $trait): self + { + $traitUse = new Stmt\TraitUse([new Name\FullyQualified(ltrim($trait, '\\'))]); + $lastTraitUse = null; + + foreach ($this->node->stmts as $index => $stmt) { + if (! $stmt instanceof Stmt\TraitUse) { + continue; + } + + $lastTraitUse = $index; + } + + if ($lastTraitUse !== null) { + array_splice($this->node->stmts, $lastTraitUse + 1, 0, [$traitUse]); + } else { + array_splice($this->node->stmts, 0, 0, $this->node->stmts === [] ? [$traitUse] : [$traitUse, Printer::blankLine()]); + } + + return $this; + } + + /** + * @param array $arguments + */ + public function addAttribute(string $name, array $arguments = []): self + { + Attributes::add($this->node, $name, $arguments); + + return $this; + } + + public function removeAttribute(string $name): self + { + Attributes::remove($this->node, $name); + + return $this; + } + + public function setComment(?string $comment): self + { + $this->node->setAttribute('comments', $comment !== null ? [DocComment::from($comment)] : []); + + return $this; + } + + public function hasMethod(string $name): bool + { + return $this->findMethod($name) !== null; + } + + public function getMethod(string $name): PhpMethod + { + $method = $this->findMethod($name) ?? throw new MethodWasMissing($this->getName(), $name); + + return new PhpMethod($method); + } + + /** + * Adds a public method, replacing any existing method with the same name. + */ + public function addMethod(string $name): PhpMethod + { + $method = new Stmt\ClassMethod($name, ['flags' => Modifiers::PUBLIC, 'stmts' => []]); + $index = $this->findMethodIndex($name); + + if ($index !== null) { + $this->node->stmts[$index] = $method; + } else { + array_push($this->node->stmts, ...$this->node->stmts === [] ? [$method] : [Printer::blankLine(), $method]); + } + + return new PhpMethod($method); + } + + public function removeMethod(string $name): self + { + $index = $this->findMethodIndex($name); + + if ($index !== null) { + array_splice($this->node->stmts, $index, 1); + } + + return $this; + } + + private function setModifier(int $modifier, bool $enabled): self + { + $this->node->flags = $enabled + ? $this->node->flags | $modifier + : $this->node->flags & ~$modifier; + + return $this; + } + + private function findMethod(string $name): ?Stmt\ClassMethod + { + return array_find($this->node->getMethods(), static fn (Stmt\ClassMethod $method) => $method->name->toLowerString() === strtolower($name)); + } + + private function findMethodIndex(string $name): ?int + { + return array_find_key($this->node->stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\ClassMethod && $stmt->name->toLowerString() === strtolower($name)); + } +} diff --git a/packages/generation/src/Php/PhpFile.php b/packages/generation/src/Php/PhpFile.php new file mode 100644 index 0000000000..2baaa960be --- /dev/null +++ b/packages/generation/src/Php/PhpFile.php @@ -0,0 +1,340 @@ + $importsUsedOnLoad + */ + private function __construct( + private array $stmts, + private readonly ?array $originalStmts = null, + private readonly ?array $tokens = null, + private readonly array $importsUsedOnLoad = [], + ) {} + + public static function create(?string $namespace = null): self + { + return new self($namespace ? [new Stmt\Namespace_(new Name($namespace))] : []); + } + + public static function fromCode(string $code): self + { + [$originalStmts, $tokens] = SourceParser::parse($code); + + $cloner = new Cloner(); + $stmts = $cloner->cloneStatements($originalStmts); + + return new self($stmts, $originalStmts, $tokens, $cloner->importKeys); + } + + public static function fromPath(string $path): self + { + return self::fromCode(Filesystem\read_file($path)); + } + + public function getNamespace(): ?string + { + return $this->findNamespace()?->name?->toString(); + } + + public function setNamespace(string $namespace): self + { + $existing = $this->findNamespace(); + + if ($existing !== null) { + $existing->name = new Name($namespace); + + return $this; + } + + $declares = array_filter($this->stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\Declare_); + $stmts = array_filter($this->stmts, static fn (Stmt $stmt) => ! $stmt instanceof Stmt\Declare_); + + $this->stmts = [...$declares, new Stmt\Namespace_(new Name($namespace), array_values($stmts))]; + + return $this; + } + + public function hasStrictTypes(): bool + { + $declaration = $this->findStrictTypesDeclaration(); + + return $declaration?->value instanceof Int_ && $declaration->value->value === 1; + } + + public function setStrictTypes(bool $strictTypes = true): self + { + $declaration = $this->findStrictTypesDeclaration(); + + if ($strictTypes && $declaration !== null) { + $declaration->value = new Int_(1); + } elseif ($strictTypes) { + $position = $this->hasComment() ? 1 : 0; + + array_splice($this->stmts, $position, 0, [ + new Stmt\Declare_([new DeclareItem('strict_types', new Int_(1))]), + Printer::blankLine(), + ]); + } elseif ($declaration !== null) { + $this->removeDeclaration($declaration); + } + + return $this; + } + + public function setComment(?string $comment): self + { + if ($this->hasComment()) { + array_shift($this->stmts); + } + + if ($comment !== null) { + array_unshift($this->stmts, new Stmt\Nop(['comments' => [DocComment::from($comment)]])); + } + + return $this; + } + + /** + * Imports a class, using the given alias to refer to it. + */ + public function addUse(string $name, ?string $alias = null): self + { + $stmts = &$this->body(); + + $imports = Imports::from($stmts); + $imports->add(Stmt\Use_::TYPE_NORMAL, ltrim($name, '\\'), $alias); + $imports->insertInto($stmts); + + return $this; + } + + public function hasClass(?string $name = null): bool + { + return $this->findClass($name) !== null; + } + + public function getClass(?string $name = null): PhpClass + { + return new PhpClass($this->findClass($name) ?? throw new ClassWasMissing($name)); + } + + public function addClass(string $name): PhpClass + { + $class = new Stmt\Class_($name); + $stmts = &$this->body(); + + array_push($stmts, ...$stmts === [] ? [$class] : [Printer::blankLine(), $class]); + + return new PhpClass($class); + } + + /** + * Whether fully qualified names of implemented interfaces are imported. + */ + public function simplifyImplements(bool $simplify = true): self + { + $this->simplifyImplements = $simplify; + + return $this; + } + + /** + * Whether fully qualified names in method bodies are imported. + */ + public function simplifyNamesInBodies(bool $simplify = true): self + { + $this->simplifyNamesInBodies = $simplify; + + return $this; + } + + public function print(): string + { + $this->resolveNames(); + + self::$printer ??= new Printer(); + + $originalStmts = $this->originalStmts; + $tokens = $this->tokens; + + if ($originalStmts !== null && $tokens !== null) { + return self::$printer->printChangedFile($this->stmts, $originalStmts, $tokens); + } + + return self::$printer->printNewFile($this->stmts); + } + + private function resolveNames(): void + { + $imports = Imports::from($this->body()); + + $simplifier = new NameSimplifier( + namespace: $this->getNamespace(), + imports: $imports, + declaredClasses: $this->declaredClasses(), + simplifyImplements: $this->simplifyImplements, + simplifyBodies: $this->simplifyNamesInBodies, + ); + + $simplifier->simplify($this->stmts); + + $stmts = &$this->body(); + + $imports->insertInto($stmts); + + if (Imports::hasUnused($stmts, $simplifier->usedImports)) { + Imports::removeUnused($stmts, $this->importsUsedOnLoad, $simplifier->usedImports, $this->comments()); + } + } + + /** + * @return Stmt[] + */ + private function &body(): array + { + foreach ($this->stmts as $stmt) { + if ($stmt instanceof Stmt\Namespace_) { + return $stmt->stmts; + } + } + + return $this->stmts; + } + + private function findNamespace(): ?Stmt\Namespace_ + { + return array_find($this->stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\Namespace_); + } + + private function findClass(?string $name): ?Stmt\Class_ + { + foreach ($this->body() as $stmt) { + if (! $stmt instanceof Stmt\Class_ || $stmt->name === null) { + continue; + } + + if ($name === null || $stmt->name->toLowerString() === strtolower($name)) { + return $stmt; + } + } + + return null; + } + + /** + * @return array + */ + private function declaredClasses(): array + { + $classes = []; + + foreach ($this->body() as $stmt) { + if (! $stmt instanceof Stmt\ClassLike || $stmt->name === null) { + continue; + } + + $classes[$stmt->name->toLowerString()] = true; + } + + return $classes; + } + + private function findStrictTypesDeclaration(): ?DeclareItem + { + foreach ($this->stmts as $stmt) { + if (! $stmt instanceof Stmt\Declare_) { + continue; + } + + foreach ($stmt->declares as $declare) { + if ($declare->key->toLowerString() === 'strict_types') { + return $declare; + } + } + } + + return null; + } + + private function removeDeclaration(DeclareItem $declaration): void + { + foreach ($this->stmts as $index => $stmt) { + if (! $stmt instanceof Stmt\Declare_ || ! in_array($declaration, $stmt->declares, strict: true)) { + continue; + } + + $stmt->declares = array_values(array_filter($stmt->declares, static fn (DeclareItem $item) => $item !== $declaration)); + + if ($stmt->declares === []) { + array_splice($this->stmts, $index, 1); + } + + return; + } + } + + /** + * The comments in the original code, and the ones that were added to the file, its classes, or method bodies. + */ + private function comments(): string + { + $comments = ''; + + foreach ($this->tokens ?? [] as $token) { + if (! $token->is([T_COMMENT, T_DOC_COMMENT])) { + continue; + } + + $comments .= $token->text . "\n"; + } + + foreach ([...$this->stmts, ...$this->body()] as $stmt) { + foreach ($stmt->getComments() as $comment) { + $comments .= $comment->getText() . "\n"; + } + + if (! $stmt instanceof Stmt\Class_) { + continue; + } + + foreach ($stmt->getMethods() as $method) { + $comments .= $method->getAttribute(MethodBody::ATTRIBUTE)?->comments(); + } + } + + return $comments; + } + + private function hasComment(): bool + { + $first = $this->stmts[0] ?? null; + + return $first instanceof Stmt\Nop && ! Printer::isBlankLine($first) && $first->getComments() !== []; + } +} diff --git a/packages/generation/src/Php/PhpMethod.php b/packages/generation/src/Php/PhpMethod.php new file mode 100644 index 0000000000..7f34a56e57 --- /dev/null +++ b/packages/generation/src/Php/PhpMethod.php @@ -0,0 +1,69 @@ +node->name->toString(); + } + + public function setReturnType(?string $type): self + { + $this->node->returnType = $type !== null ? TypeParser::parse($type) : null; + + return $this; + } + + public function addParameter(string $name, ?string $type = null): self + { + $this->node->params[] = new Param( + var: new Variable(ltrim($name, '$')), + type: $type !== null ? TypeParser::parse($type) : null, + ); + + return $this; + } + + public function setBody(string $code): self + { + [$body, $stmts] = MethodBody::parse($code); + + $this->node->stmts = $stmts; + $this->node->setAttribute(MethodBody::ATTRIBUTE, $body); + $this->node->setAttribute('origNode', null); + + return $this; + } + + /** + * @param array $arguments + */ + public function addAttribute(string $name, array $arguments = []): self + { + Attributes::add($this->node, $name, $arguments); + + return $this; + } + + public function removeAttribute(string $name): self + { + Attributes::remove($this->node, $name); + + return $this; + } +} diff --git a/packages/generation/src/Php/Printer.php b/packages/generation/src/Php/Printer.php new file mode 100644 index 0000000000..56543998db --- /dev/null +++ b/packages/generation/src/Php/Printer.php @@ -0,0 +1,284 @@ + PhpVersion::fromComponents(8, 5), + 'shortArraySyntax' => true, + ]); + } + + /** + * A statement that separates the surrounding statements by a blank line when they are inserted into existing code. + */ + public static function blankLine(): Stmt\Nop + { + return new Stmt\Nop([self::BLANK_LINE_ATTRIBUTE => true]); + } + + public static function isBlankLine(Node $node): bool + { + return $node instanceof Stmt\Nop && $node->getAttribute(self::BLANK_LINE_ATTRIBUTE) === true; + } + + /** + * @param Stmt[] $stmts + */ + public function printNewFile(array $stmts): string + { + $this->resetState(); + $this->preprocessNodes($stmts); + + return 'newline . $this->newline . $this->pBlocks($stmts) . $this->newline; + } + + /** + * @param Stmt[] $stmts + * @param Stmt[] $originalStmts + * @param Token[] $tokens + */ + public function printChangedFile(array $stmts, array $originalStmts, array $tokens): string + { + $code = $this->printFormatPreserving($stmts, $originalStmts, $tokens); + $code = preg_replace('/[ \t]*' . self::BLANK_LINE_MARKER . '/', '', $code); + + return str_ends_with($code, $this->newline) ? $code : $code . $this->newline; + } + + #[Override] + protected function p(Node $node, int $precedence = self::MAX_PRECEDENCE, int $lhsPrecedence = self::MAX_PRECEDENCE, bool $parentFormatPreserved = false): string + { + if ($this->origTokens === null) { + return $this->{'p' . $node->getType()}($node, $precedence, $lhsPrecedence); + } + + $original = $node->getAttribute('origNode'); + $canBeCopied = $parentFormatPreserved || ! $node instanceof Stmt\InlineHTML; + + if ($original instanceof Node && $canBeCopied && Changes::isUnchanged($node)) { + $start = $original->getStartTokenPos(); + $indentAdjustment = $this->indentLevel - $this->origTokens->getIndentationBefore($start); + + return $this->origTokens->getTokenCode($start, $original->getEndTokenPos() + 1, $indentAdjustment); + } + + return parent::p($node, $precedence, $lhsPrecedence, $parentFormatPreserved); + } + + #[Override] + protected function pArray(array $nodes, array $origNodes, int &$pos, int $indentAdjustment, string $parentNodeClass, string $subNodeName, ?int $fixup): ?string + { + if ($subNodeName !== 'attrGroups' || $nodes !== [] || $origNodes === []) { + return parent::pArray($nodes, $origNodes, $pos, $indentAdjustment, $parentNodeClass, $subNodeName, $fixup); + } + + $code = $this->origTokens->getTokenCode($pos, $origNodes[0]->getStartTokenPos(), $indentAdjustment); + $pos = $this->origTokens->skipRightWhitespace($origNodes[array_key_last($origNodes)]->getEndTokenPos() + 1); + + return $code; + } + + #[Override] + protected function pStmt_Nop(Stmt\Nop $node): string + { + return self::isBlankLine($node) ? self::BLANK_LINE_MARKER : ''; + } + + #[Override] + protected function pStmt_Declare(Stmt\Declare_ $node): string + { + $stmts = $node->stmts; + + return 'declare(' . $this->pCommaSeparated($node->declares) . ')' . ($stmts !== null ? ' {' . $this->pStmts($stmts) . $this->nl . '}' : ';'); + } + + #[Override] + protected function pStmt_Namespace(Stmt\Namespace_ $node): string + { + $name = $node->name; + + if (! $this->canUseSemicolonNamespaces || $name === null) { + return parent::pStmt_Namespace($node); + } + + $stmts = $this->pBlocks($node->stmts); + + return 'namespace ' . $this->p($name) . ';' . ($stmts !== '' ? $this->newline . $this->nl . $stmts : ''); + } + + #[Override] + protected function pClassCommon(Stmt\Class_ $node, string $afterClassToken): string + { + $extends = $node->extends; + $declaration = + $this->pAttrGroups($node->attrGroups, $node->name === null) + . $this->pModifiers($node->flags) + . 'class' + . $afterClassToken + . ($extends !== null ? ' extends ' . $this->p($extends) : '') + . ($node->implements !== [] ? ' implements ' . $this->pCommaSeparated($node->implements) : ''); + + $this->indent(); + $members = $this->pBlocks($node->stmts); + $this->outdent(); + + if ($members === '') { + return $declaration . ' {}'; + } + + return $declaration . $this->nl . '{' . $this->newline . $this->indentation(1) . $members . $this->nl . '}'; + } + + #[Override] + protected function pStmt_ClassMethod(Stmt\ClassMethod $node): string + { + $multiline = $this->shouldBreakParameters($node->params); + $returnType = $node->returnType; + + $signature = + $this->pAttrGroups($node->attrGroups) + . $this->pModifiers($node->flags) + . 'function ' + . ($node->byRef ? '&' : '') + . $node->name + . '(' + . $this->pParams($node->params) + . ')' + . ($returnType !== null ? ': ' . $this->p($returnType) : ''); + + if ($node->stmts === null) { + return $signature . ';'; + } + + $body = $this->pMethodBody($node); + + if ($body === '') { + return $signature . ' {}'; + } + + return $signature . ($multiline ? ' {' : $this->nl . '{') . $body . $this->nl . '}'; + } + + #[Override] + protected function pParams(array $params): string + { + if (! $this->shouldBreakParameters($params)) { + return $this->pCommaSeparated($params); + } + + $wasMultiline = $this->multilineParameters; + $this->multilineParameters = true; + $code = $this->pCommaSeparatedMultiline($params, trailingComma: true) . $this->nl; + $this->multilineParameters = $wasMultiline; + + return $code; + } + + #[Override] + protected function pParam(Node\Param $node): string + { + if (! $this->multilineParameters || $node->attrGroups === []) { + return parent::pParam($node); + } + + $attributes = ''; + + foreach ($node->attrGroups as $group) { + $attributes .= $this->p($group) . $this->nl; + } + + return $attributes . parent::pParam(clone($node, ['attrGroups' => []])); + } + + /** + * Prints statements separated by blank lines, keeping related statements such as imports together. + * + * @param Node[] $nodes + */ + private function pBlocks(array $nodes): string + { + $code = ''; + $previous = null; + + foreach ($nodes as $node) { + if (self::isBlankLine($node)) { + continue; + } + + if ($previous !== null) { + $code .= $this->belongTogether($previous, $node) ? $this->nl : $this->newline . $this->nl; + } + + $comments = $node->getComments(); + + if ($node instanceof Stmt\Nop) { + $code .= $this->pComments($comments); + } else { + $code .= ($comments !== [] ? $this->pComments($comments) . $this->nl : '') . $this->p($node); + } + + $previous = $node; + } + + return $code; + } + + private function belongTogether(Node $previous, Node $node): bool + { + if ($previous instanceof Stmt\Use_ && $node instanceof Stmt\Use_) { + return $previous->type === $node->type; + } + + return $previous::class === $node::class && ($node instanceof Stmt\TraitUse || $node instanceof Stmt\EnumCase); + } + + private function pMethodBody(Stmt\ClassMethod $node): string + { + $methodBody = $node->getAttribute(MethodBody::ATTRIBUTE); + + if (! $methodBody instanceof MethodBody) { + return $this->pStmts($node->stmts ?? []); + } + + $code = $methodBody->print($this->indentation(1)); + + return $code !== '' ? $this->newline . $code : ''; + } + + /** + * @param Node\Param[] $params + */ + private function shouldBreakParameters(array $params): bool + { + return array_any($params, static fn (Node\Param $param) => $param->flags !== 0 || $param->attrGroups !== [] || $param->getComments() !== []); + } + + private function indentation(int $additionalLevels): string + { + return substr($this->nl, strlen($this->newline)) . str_repeat(' ', $additionalLevels); + } +} diff --git a/packages/generation/src/Php/SimplifiesClassNames.php b/packages/generation/src/Php/SimplifiesClassNames.php deleted file mode 100644 index 9843a89369..0000000000 --- a/packages/generation/src/Php/SimplifiesClassNames.php +++ /dev/null @@ -1,164 +0,0 @@ -simplifyClassNamesInBodies = $simplify; - - return $this; - } - - public function simplifyImplements(bool $simplify = true): self - { - $this->simplifyImplements = $simplify; - - return $this; - } - - public function addUse(string $use): self - { - $this->aliases[$use] = null; - - return $this; - } - - public function setAlias(string $class, string $alias): self - { - if (isset($this->aliases[$class])) { - unset($this->aliases[$class]); - } - - $this->aliases[$class] = $alias; - - return $this; - } - - private function simplifyClassNames(PhpFile $file): PhpFile - { - foreach ($file->getNamespaces() as $namespace) { - foreach ($namespace->getClasses() as $class) { - $types = []; - $functions = []; - - if ($this->simplifyImplements) { - foreach ($class->getImplements() as $implement) { - $types[] = $implement; - } - } - - foreach ($class->getAttributes() as $attribute) { - $types[] = $attribute->getName(); - } - - foreach ($class->getTraits() as $trait) { - $types[] = $trait->getName(); - } - - foreach ($class->getMethods() as $method) { - $types[] = $method->getReturnType(true); - - foreach ($method->getParameters() as $parameter) { - $types[] = $parameter->getType(true); - - foreach ($parameter->getAttributes() as $attribute) { - $types[] = $attribute->getName(); - } - } - - foreach ($method->getAttributes() as $attribute) { - $types[] = $attribute->getName(); - } - - if ($this->simplifyClassNamesInBodies) { - $methodBody = $method->getBody(); - $fqcnMatches = $this->extractFqcnFromBody($methodBody); - - foreach (array_filter($fqcnMatches) as $fqcn) { - if (str_contains($methodBody, "/*(f*/\\{$fqcn}")) { - $namespace->addUseFunction($fqcn); - - continue; - } - - if (str_contains($methodBody, "/*(n*/\\{$fqcn}")) { - $namespace->addUse($fqcn); - - continue; - } - - $methodBody = str_replace( - search: '\\' . $fqcn, - replace: (string) str($fqcn)->afterLast('\\'), - subject: $methodBody, - ); - - $namespace->addUse($fqcn); - } - - $method->setBody($methodBody); - } - } - - array_map( - function ($param) use (&$types): void { - $types[] = $param->getType(true); - }, - $class->getProperties(), - ); - - foreach ($this->aliases as $class => $alias) { - $namespace->addUse($class, $alias); - } - - foreach (array_filter($types) as $type) { - if (is_string($type)) { - $namespace->addUse($type); - - continue; - } - - foreach ($type->getTypes() as $subtype) { - if (! $subtype->isClass()) { - continue; - } - - if ($subtype->isClassKeyword()) { - continue; - } - - $namespace->addUse((string) $subtype); - } - } - } - } - - return $file; - } - - private function extractFqcnFromBody(string $body): array - { - preg_match_all('/(?:\\\\?[A-Za-z_][\w\d_]*\\\\)+[A-Za-z_][\w\d_]*/', $body, $matches); - - return array_filter(array_unique( - array_map(fn (string $fqcn) => rtrim(ltrim($fqcn, '\\'), ':'), $matches[0]), - )); - } -} diff --git a/packages/generation/src/Php/SourceParser.php b/packages/generation/src/Php/SourceParser.php new file mode 100644 index 0000000000..6eca336fd6 --- /dev/null +++ b/packages/generation/src/Php/SourceParser.php @@ -0,0 +1,49 @@ +createForVersion(PhpVersion::fromComponents(8, 5)); + + try { + $stmts = self::$parser->parse($code) ?? []; + } catch (Error $error) { + throw new PhpCodeWasInvalid($error); + } + + return [$stmts, self::$parser->getTokens()]; + } + + public static function parseExpression(string $code): Expr + { + [$stmts] = self::parse("expr; + } +} diff --git a/packages/generation/src/Php/StubFileGenerator.php b/packages/generation/src/Php/StubFileGenerator.php index b2db010057..81b3f946e8 100644 --- a/packages/generation/src/Php/StubFileGenerator.php +++ b/packages/generation/src/Php/StubFileGenerator.php @@ -26,7 +26,7 @@ final class StubFileGenerator * @param StubFile $stubFile The stub file to use for the generation. It must be of type CLASS_FILE. * @param string $targetPath The path where the generated file will be saved including the filename and extension. * @param bool $shouldOverride Whether the generator should override the file if it already exists. - * @param array $replacements An array of key-value pairs to replace in the stub file. + * @param array $replacements An array of key-value pairs to replace in the stub file. * The keys are the placeholders in the stub file (e.g. 'DummyNamespace') * The values are the replacements for the placeholders (e.g. 'App\Models') * @@ -46,35 +46,37 @@ public function generateClassFile( throw new FileGenerationFailedException(sprintf('The stub file must be of type CLASS_FILE, %s given.', $stubFile->type->name)); } - if (Filesystem\is_file($targetPath) && ! $shouldOverride) { + if (! $shouldOverride && Filesystem\is_file($targetPath)) { throw new FileGenerationWasAborted(sprintf('The file %s already exists and the operation has been aborted.', $targetPath)); } $this->prepareFilesystem($targetPath); // Transform stub to class - $namespace = registered_namespace($targetPath); - $classname = to_base_class_name($targetPath); - $classManipulator = new ClassManipulator($stubFile->filePath) - ->setNamespace($namespace) - ->setClassName($classname); + $code = Filesystem\read_file($stubFile->filePath); foreach ($replacements as $placeholder => $replacement) { - if (! is_string($replacement)) { + if ($replacement === null) { continue; } - $classManipulator->manipulate(fn (ImmutableString $code) => $code->replace($placeholder, $replacement)); + $code = str_replace($placeholder, $replacement, $code); } + $namespace = registered_namespace($targetPath); + $classname = to_base_class_name($targetPath); + $classManipulator = new ClassManipulator(PhpFile::fromCode($code)) + ->setNamespace($namespace) + ->setClassName($classname); + // Run all manipulations $classManipulator = array_reduce( array: $manipulations, - callback: fn (ClassManipulator $manipulator, Closure $manipulation) => $manipulation($manipulator), + callback: static fn (ClassManipulator $manipulator, Closure $manipulation) => $manipulation($manipulator), initial: $classManipulator, ); - if (Filesystem\is_file($targetPath) && $shouldOverride) { + if ($shouldOverride && Filesystem\is_file($targetPath)) { Filesystem\delete_file($targetPath); } @@ -88,7 +90,7 @@ public function generateClassFile( * @param StubFile $stubFile The stub file to use for the generation. It must be of type RAW_FILE. * @param string $targetPath The path where the generated file will be saved including the filename and extension. * @param bool $shouldOverride Whether the generator should override the file if it already exists. - * @param array $replacements An array of key-value pairs to replace in the stub file. + * @param array $replacements An array of key-value pairs to replace in the stub file. * The keys are the placeholders in the stub file (e.g. 'dummy-content') * The values are the replacements for the placeholders (e.g. 'real content') * @@ -108,7 +110,7 @@ public function generateRawFile( throw new FileGenerationFailedException(sprintf('The stub file must be of type RAW_FILE, "%s" given.', $stubFile->type->name)); } - if (Filesystem\is_file($targetPath) && ! $shouldOverride) { + if (! $shouldOverride && Filesystem\is_file($targetPath)) { throw new FileGenerationWasAborted(sprintf('The file "%s" already exists and the operation has been aborted.', $targetPath)); } @@ -116,7 +118,7 @@ public function generateRawFile( $fileContent = Filesystem\read_file($stubFile->filePath); foreach ($replacements as $placeholder => $replacement) { - if (! is_string($replacement)) { + if ($replacement === null) { continue; } @@ -126,11 +128,11 @@ public function generateRawFile( // Run all manipulations $fileContent = array_reduce( array: $manipulations, - callback: fn (ImmutableString $content, Closure $manipulation) => $manipulation($content), + callback: static fn (ImmutableString $content, Closure $manipulation) => $manipulation($content), initial: $fileContent, ); - if (Filesystem\is_file($targetPath) && $shouldOverride) { + if ($shouldOverride && Filesystem\is_file($targetPath)) { Filesystem\delete_file($targetPath); } @@ -148,10 +150,6 @@ public function generateRawFile( */ private function prepareFilesystem(string $targetPath): void { - // Recursively create directories before writing the file - $directory = dirname($targetPath); - if (! is_dir($directory)) { - mkdir($directory, recursive: true); - } + Filesystem\create_directory_for_file($targetPath); } } diff --git a/packages/generation/src/Php/TypeParser.php b/packages/generation/src/Php/TypeParser.php new file mode 100644 index 0000000000..ee9c3ec6f8 --- /dev/null +++ b/packages/generation/src/Php/TypeParser.php @@ -0,0 +1,102 @@ + 1) { + return new UnionType(array_map(self::parse(...), $parts)); + } + + if (count($parts = self::split($type, '&')) > 1) { + return new IntersectionType(array_map(self::parse(...), $parts)); + } + + if (str_starts_with($type, '(') && str_ends_with($type, ')')) { + return self::parse(substr($type, 1, -1)); + } + + $lowercased = strtolower($type); + + if (in_array($lowercased, self::BUILTIN_TYPES, strict: true)) { + return new Identifier($lowercased); + } + + if ($lowercased === 'self' || $lowercased === 'parent') { + return new Name($lowercased); + } + + return new Name\FullyQualified(ltrim($type, '\\')); + } + + /** + * @return string[] + */ + private static function split(string $type, string $separator): array + { + $parts = []; + $depth = 0; + $current = ''; + + foreach (str_split($type) as $character) { + $depth += match ($character) { + '(' => 1, + ')' => -1, + default => 0, + }; + + if ($character === $separator && $depth === 0) { + $parts[] = $current; + $current = ''; + + continue; + } + + $current .= $character; + } + + $parts[] = $current; + + return $parts; + } +} diff --git a/packages/generation/src/Php/ValueExporter.php b/packages/generation/src/Php/ValueExporter.php new file mode 100644 index 0000000000..82cc7ef6f6 --- /dev/null +++ b/packages/generation/src/Php/ValueExporter.php @@ -0,0 +1,75 @@ + SourceParser::parseExpression($value->code), + $value instanceof UnitEnum => new Expr\ClassConstFetch(new Name\FullyQualified($value::class), $value->name), + $value === null => new Expr\ConstFetch(new Name('null')), + is_bool($value) => new Expr\ConstFetch(new Name($value ? 'true' : 'false')), + is_int($value) => self::exportInteger($value), + is_float($value) => new Scalar\Float_($value), + is_string($value) => new Scalar\String_($value), + is_array($value) => self::exportArray($value), + default => throw new ValueWasNotExportable($value), + }; + } + + /** + * @param array $arguments + * @return Arg[] + */ + public static function exportArguments(array $arguments): array + { + $args = []; + + foreach ($arguments as $name => $value) { + $args[] = new Arg(self::export($value), name: is_string($name) ? new Identifier($name) : null); + } + + return $args; + } + + private static function exportInteger(int $value): Expr + { + if ($value === PHP_INT_MIN) { + return new Expr\BinaryOp\Minus(new Expr\UnaryMinus(new Scalar\Int_(PHP_INT_MAX)), new Scalar\Int_(1)); + } + + return $value < 0 ? new Expr\UnaryMinus(new Scalar\Int_(-$value)) : new Scalar\Int_($value); + } + + private static function exportArray(array $values): Expr\Array_ + { + $items = []; + $nextIndex = 0; + + foreach ($values as $key => $value) { + $items[] = new ArrayItem(self::export($value), $key === $nextIndex ? null : self::export($key)); + + if (is_int($key)) { + $nextIndex = max($nextIndex, $key + 1); + } + } + + return new Expr\Array_($items, ['kind' => Expr\Array_::KIND_SHORT]); + } +} diff --git a/packages/generation/tests/Php/ClassManipulatorTest.php b/packages/generation/tests/Php/ClassManipulatorTest.php index 3c918215a2..25e7586148 100644 --- a/packages/generation/tests/Php/ClassManipulatorTest.php +++ b/packages/generation/tests/Php/ClassManipulatorTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Tempest\Generation\Php\ClassManipulator; -use Tempest\Generation\Tests\Php\Fixtures\ClassWithDummyStringToBeReplacedByFqcn; +use Tempest\Generation\Php\PhpFile; use Tempest\Generation\Tests\Php\Fixtures\ClassWithMethodParameterAttributes; use Tempest\Generation\Tests\Php\Fixtures\ClassWithTraitInAnotherNamespace; use Tempest\Generation\Tests\Php\Fixtures\CreateMigrationsTable; @@ -15,7 +15,7 @@ use Tempest\Generation\Tests\Php\Fixtures\SampleNamespace\DummyFqcn; use Tempest\Generation\Tests\Php\Fixtures\TestAttribute; use Tempest\Generation\Tests\Php\Fixtures\WelcomeController; -use Tempest\Support\Str\ImmutableString; +use Tempest\Support\Filesystem; /** * @internal @@ -159,17 +159,6 @@ public function transforms_functions(): void $this->assertMatchesSnapshot($class->print()); } - #[Test] - public function manipulates_classes(): void - { - $class = new ClassManipulator(WelcomeController::class); - $class->setNamespace('App\\Controllers'); - $class->setClassName('WelcomeController'); - $class->manipulate(fn (ImmutableString $string) => $string->replace('welcome', 'home')); - - $this->assertMatchesSnapshot($class->print()); - } - #[Test] public function simplifies_traits_from_another_namespace(): void { @@ -187,10 +176,15 @@ public function simplifies_method_parameter_attributes(): void } #[Test] - public function make_replacements_before_converting_fqcn(): void + public function simplifies_fully_qualified_names_in_loaded_code(): void { - $class = new ClassManipulator(ClassWithDummyStringToBeReplacedByFqcn::class); - $class->manipulate(fn (ImmutableString $string) => $string->replace("'fqcn-to-be-replaced'", sprintf('%s::class', DummyFqcn::class))); + $code = str_replace( + "'fqcn-to-be-replaced'", + sprintf('\\%s::class', DummyFqcn::class), + Filesystem\read_file(__DIR__ . '/Fixtures/ClassWithDummyStringToBeReplacedByFqcn.php'), + ); + + $class = new ClassManipulator(PhpFile::fromCode($code)); $this->assertMatchesSnapshot($class->print()); } diff --git a/packages/generation/tests/Php/Fixtures/SampleNamespace/SampleEnum.php b/packages/generation/tests/Php/Fixtures/SampleNamespace/SampleEnum.php new file mode 100644 index 0000000000..e8ec1787ee --- /dev/null +++ b/packages/generation/tests/Php/Fixtures/SampleNamespace/SampleEnum.php @@ -0,0 +1,10 @@ +addClassAttribute(TestAttribute::class, [ + 'name' => 'example', + 'case' => SampleEnum::FIRST, + 'values' => [1, -2, 'three' => true, 'four' => null], + 'expression' => new Literal('PHP_VERSION_ID >= 80500'), + ]); + + $this->assertMatchesSnapshot($class->print()); + } + + #[Test] + public function rejects_values_that_cannot_be_exported(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + + $this->expectException(ValueWasNotExportable::class); + + $class->addClassAttribute(TestAttribute::class, [new stdClass()]); + } + + #[Test] + public function keeps_multiline_strings_in_method_bodies_intact(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addMethod('render', body: <<<'PHP' + $title = 'first line + second line'; + + return <<{$title} + HTML; + PHP, returnType: 'string'); + + $this->assertMatchesSnapshot($class->print()); + } + + #[Test] + public function adds_members_to_loaded_classes_without_reformatting_them(): void + { + $class = new ClassManipulator(CreateMigrationsTable::class); + $class->addTrait(ExampleTrait::class); + $class->addMethod('down', body: 'return null;', returnType: '?' . FakeQueryStatement::class); + + $this->assertMatchesSnapshot($class->print()); + } + + #[Test] + public function loads_classes_from_reflection(): void + { + $class = new ClassManipulator(new ReflectionClass(ClassFromFile::class)); + + $this->assertSame(ClassFromFile::class, $class->getClassName()); + } + + #[Test] + public function throws_when_adding_an_attribute_to_a_missing_method(): void + { + $class = new ClassManipulator(ClassFromFile::class); + + $this->expectException(MethodWasMissing::class); + + $class->addMethodAttribute('missing', TestAttribute::class); + } + + #[Test] + public function does_not_import_names_that_would_change_existing_references(): void + { + $file = PhpFile::fromCode(<<<'PHP' + print(); + + $this->assertStringContainsString('return [Thing::class, \Vendor\Thing::class, count([]), \Vendor\count()];', $code); + $this->assertStringNotContainsString('use ', $code); + $this->assertSame($code, $file->print()); + } + + #[Test] + public function reuses_grouped_imports(): void + { + $file = PhpFile::fromCode(<<<'PHP' + addUse('Vendor\Thing'); + + $this->assertSame(1, substr_count($file->print(), 'Thing')); + } + + #[Test] + public function keeps_qualified_function_and_constant_names_working_when_the_namespace_changes(): void + { + $file = PhpFile::fromCode(<<<'PHP' + setNamespace('NewNamespace'); + + $this->assertStringContainsString('return \Old\Sub\f(\Old\Sub\VALUE);', $file->print()); + } + + #[Test] + public function keeps_imports_used_in_doc_comments(): void + { + $file = PhpFile::fromCode(<<<'PHP' + getClass()->removeAttribute('Vendor\Thing'); + + $this->assertStringContainsString('use Vendor\Thing;', $file->print()); + } + + #[Test] + public function enables_strict_types_when_declared_as_disabled(): void + { + $file = PhpFile::fromCode("assertFalse($file->hasStrictTypes()); + + $file->setStrictTypes(); + + $this->assertTrue($file->hasStrictTypes()); + $this->assertStringContainsString('declare(strict_types=1);', $file->print()); + } + + #[Test] + public function exports_the_minimum_integer(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addClassAttribute(TestAttribute::class, [PHP_INT_MIN]); + + $code = $class->print(); + + $this->assertStringContainsString('#[TestAttribute(-9223372036854775807 - 1)]', $code); + } + + #[Test] + public function keeps_strings_in_nested_interpolations_intact(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addMethod('render', body: "return \"{\$this->format(\"first\n\$value\")}\";", returnType: 'string'); + + $this->assertStringContainsString("\"{\$this->format(\"first\n\$value\")}\"", $class->print()); + } + + #[Test] + public function keeps_tab_indented_heredocs_valid(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addMethod('render', body: "return <<print(); + + $this->assertStringContainsString("<<assertNotEmpty(token_get_all($code, TOKEN_PARSE)); + } + + #[Test] + public function does_not_import_names_that_conflict_with_simplified_names(): void + { + $file = PhpFile::fromCode(<<<'PHP' + assertStringContainsString('return [Thing::class, \Vendor\Thing::class];', $file->print()); + } + + #[Test] + public function does_not_import_functions_that_conflict_with_declared_functions(): void + { + $file = PhpFile::fromCode(<<<'PHP' + print(); + + $this->assertStringContainsString('\Vendor\target();', $code); + $this->assertStringNotContainsString('use function', $code); + } + + #[Test] + public function keeps_quoted_strings_inside_heredoc_interpolations_intact(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addMethod('render', body: "return <<format('first\nsecond')}\nTXT;", returnType: 'string'); + + $this->assertStringContainsString("'first\nsecond'", $class->print()); + } + + #[Test] + public function keeps_namespace_imports_used_in_doc_comments(): void + { + $file = PhpFile::fromCode(<<<'PHP' + getClass()->removeAttribute('Vendor\Package\Attr'); + + $this->assertStringContainsString('use Vendor\Package as Models;', $file->print()); + } + + #[Test] + public function keeps_imports_used_in_added_doc_comments(): void + { + $file = PhpFile::fromCode(<<<'PHP' + getClass()->removeAttribute('Vendor\Thing')->setComment('@extends Thing'); + + $this->assertStringContainsString('use Vendor\Thing;', $file->print()); + } + + #[Test] + public function reindents_heredocs_containing_quotes_consistently(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addMethod('render', body: "\$value = 'world';\nreturn <<assertStringContainsString(" return <<print()); + } + + #[Test] + public function keeps_strings_nested_in_heredoc_interpolations_intact(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addMethod('render', body: "return <<format(\"outer{\$this->format(\"first\n\$value\")}\")}\nTXT;", returnType: 'string'); + + $this->assertStringContainsString("\"first\n\$value\"", $class->print()); + } + + #[Test] + public function replaces_bodies_of_loaded_methods_with_fully_qualified_signatures(): void + { + $file = PhpFile::fromCode( + "getClass()->getMethod('make')->setBody('return null;'); + + $code = $file->print(); + + $this->assertStringContainsString("public function make(): Result\n {\n return null;\n }", $code); + $this->assertNotEmpty(token_get_all($code, TOKEN_PARSE)); + } + + #[Test] + public function keeps_global_functions_and_constants_when_moving_code_into_a_namespace(): void + { + $file = PhpFile::fromCode("setNamespace('Changed'); + + $this->assertStringContainsString('return \\answer(\\VALUE, true);', $file->print()); + } + + #[Test] + public function keeps_imports_used_in_comments_of_added_method_bodies(): void + { + $file = PhpFile::fromCode(<<<'PHP' + getClass()->getMethod('mark')->removeAttribute('Other\\Marker')->setBody("/** @var Marker \$marker */\n\$marker = null;"); + + $this->assertStringContainsString('use Other\\Marker;', $file->print()); + } + + #[Test] + public function simplifies_names_in_classes_nested_in_added_method_bodies(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addMethod('make', body: 'return new class { public function result(): \\Vendor\\Result { return new \\Vendor\\Result(); } };', returnType: 'object'); + + $code = $class->print(); + + $this->assertStringContainsString('use Vendor\\Result;', $code); + $this->assertStringContainsString('public function result(): Result { return new Result(); }', $code); + } + + #[Test] + public function keeps_whitespace_only_lines_in_heredocs(): void + { + $class = new ClassGenerator('Example', namespace: 'App'); + $class->addMethod('render', body: "return <<assertStringContainsString(" return <<print()); + } +} diff --git a/packages/generation/tests/Php/StubFileTest.php b/packages/generation/tests/Php/StubFileTest.php new file mode 100644 index 0000000000..730760239a --- /dev/null +++ b/packages/generation/tests/Php/StubFileTest.php @@ -0,0 +1,72 @@ +path = tempnam(sys_get_temp_dir(), 'stub'); + } + + protected function tearDown(): void + { + unlink($this->path); + } + + #[Test] + public function resolves_class_names_to_their_file(): void + { + $stub = StubFile::from(ClassFromFile::class); + + $this->assertSame(StubFileType::CLASS_FILE, $stub->type); + $this->assertSame(realpath(__DIR__ . '/Fixtures/ClassFromFile.php'), $stub->filePath); + } + + #[Test] + #[DataProvider('files')] + public function detects_the_type_of_files(string $contents, StubFileType $type): void + { + file_put_contents($this->path, $contents); + + $this->assertSame($type, StubFile::from($this->path)->type); + } + + public static function files(): iterable + { + yield 'class' => [" [" [" [" [" [" [" [" [" ["
{{ \$title }}
\n", StubFileType::RAW_FILE]; + yield 'invalid php' => ["expectException(InvalidArgumentException::class); + + StubFile::from(__DIR__ . '/missing.php'); + } +} diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__creates_class_from_scratch__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__creates_class_from_scratch__1.txt index 6dfc70e63c..7ca713d2fd 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__creates_class_from_scratch__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__creates_class_from_scratch__1.txt @@ -11,8 +11,8 @@ final readonly class CreateUsersTable implements \Tempest\Generation\Tests\Php\F public function up(): FakeQueryStatement { return (new FakeCreateTableStatement(MigrationModel::table())) - ->primary() - ->text('name'); + ->primary() + ->text('name'); } public function getName(): string diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__does_not_simplify_class_names__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__does_not_simplify_class_names__1.txt index c414054191..892761771b 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__does_not_simplify_class_names__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__does_not_simplify_class_names__1.txt @@ -9,7 +9,7 @@ class CreateUsersTable public function up(): FakeQueryStatement { return (new \Tempest\Generation\Tests\Fixtures\Database\FakeCreateTableStatement(\Tempest\Generation\Tests\Fixtures\Database\MigrationModel::table())) - ->primary() - ->text('name'); + ->primary() + ->text('name'); } } diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_class_modifiers__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_class_modifiers__1.txt index c598939ec8..07e6ab8b14 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_class_modifiers__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_class_modifiers__1.txt @@ -5,6 +5,4 @@ namespace App; /** * This creates a users table. */ -final readonly class CreateUsersTable -{ -} +final readonly class CreateUsersTable {} diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_file_modifiers__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_file_modifiers__1.txt index 29b06953d0..a8537b21f1 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_file_modifiers__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_file_modifiers__1.txt @@ -8,6 +8,4 @@ declare(strict_types=1); namespace App; -class CreateUsersTable -{ -} +class CreateUsersTable {} diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_long_namespace__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_long_namespace__1.txt index cbabc530cd..c05eb1d73e 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_long_namespace__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_long_namespace__1.txt @@ -2,6 +2,4 @@ namespace App\Foo\Bar\Baz\Qux; -class MyClass -{ -} +class MyClass {} diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplifies_implements__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplifies_implements__1.txt index 5ec0cc74db..1486397a2a 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplifies_implements__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplifies_implements__1.txt @@ -4,6 +4,4 @@ namespace App; use Tempest\Generation\Tests\Php\Fixtures\Database\FakeMigration; -class CreateUsersTable implements FakeMigration -{ -} +class CreateUsersTable implements FakeMigration {} diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplify_class_names_by_default__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplify_class_names_by_default__1.txt index 37c69ea17e..0986199687 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplify_class_names_by_default__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplify_class_names_by_default__1.txt @@ -11,7 +11,7 @@ class CreateUsersTable public function up(): FakeQueryStatement { return (new FakeCreateTableStatement(MigrationModel::table())) - ->primary() - ->text('name'); + ->primary() + ->text('name'); } } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt index c4a395bcf2..bddeafee40 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt @@ -1,9 +1,15 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt index 2e6cb5abcb..bddeafee40 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt @@ -1,15 +1,18 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__from_file__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__from_file__1.txt index ca30f51cd7..2ca7a96e47 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__from_file__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__from_file__1.txt @@ -1,7 +1,7 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__set_aliases__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__set_aliases__1.txt index 32fa140c70..bddeafee40 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__set_aliases__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__set_aliases__1.txt @@ -1,11 +1,12 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt index 824fe09084..bddeafee40 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt @@ -1,11 +1,13 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt index 5d86c73f13..bddeafee40 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt @@ -7,7 +7,7 @@ namespace Tempest\Generation\Tests\Php\Fixtures; use Tempest\Generation\Tests\Php\Fixtures\Database\FakeCreateTableStatement; use Tempest\Generation\Tests\Php\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Php\Fixtures\Database\FakeQueryStatement; -use Tempest\Generation\Tests\Php\Fixtures\Database\MigrationModel; +use Tempest\Generation\Tests\Php\Fixtures\Database\MigrationModel as Model; use function Tempest\Database\inspect; @@ -21,7 +21,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt index 824fe09084..bddeafee40 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt @@ -1,11 +1,13 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__make_replacements_before_converting_fqcn__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_fully_qualified_names_in_loaded_code__1.txt similarity index 64% rename from packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__make_replacements_before_converting_fqcn__1.txt rename to packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_fully_qualified_names_in_loaded_code__1.txt index 9024b6eaaf..5fbc971f21 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__make_replacements_before_converting_fqcn__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_fully_qualified_names_in_loaded_code__1.txt @@ -1,8 +1,10 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt index 55cf00ebeb..df8bab639a 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt @@ -1,11 +1,13 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt index 824fe09084..170df7d6e1 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt @@ -5,7 +5,7 @@ namespace Tempest\Generation\Tests\Php\Fixtures; use Tempest\Generation\Tests\Php\Fixtures\Database\FakeCreateTableStatement; use Tempest\Generation\Tests\Php\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Php\Fixtures\Database\FakeQueryStatement; -use Tempest\Generation\Tests\Php\Fixtures\Database\MigrationModel; +use Tempest\Generation\Tests\Php\Fixtures\Database\MigrationModel as Model; use function Tempest\Database\inspect; @@ -19,7 +19,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt index c41794d92e..f996c89629 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt @@ -1,11 +1,13 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt index 25fa319b5c..3845f7f40a 100644 --- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt +++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt @@ -1,11 +1,13 @@ getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/Php/__snapshots__/PhpFileTest__adds_members_to_loaded_classes_without_reformatting_them__1.txt b/packages/generation/tests/Php/__snapshots__/PhpFileTest__adds_members_to_loaded_classes_without_reformatting_them__1.txt new file mode 100644 index 0000000000..fc55738df1 --- /dev/null +++ b/packages/generation/tests/Php/__snapshots__/PhpFileTest__adds_members_to_loaded_classes_without_reformatting_them__1.txt @@ -0,0 +1,36 @@ +getTableDefinition()->name) + ->primary() + ->text('name'); + } + + public function down(): ?FakeQueryStatement + { + return null; + } +} diff --git a/packages/generation/tests/Php/__snapshots__/PhpFileTest__exports_attribute_arguments__1.txt b/packages/generation/tests/Php/__snapshots__/PhpFileTest__exports_attribute_arguments__1.txt new file mode 100644 index 0000000000..a07c3bd6f3 --- /dev/null +++ b/packages/generation/tests/Php/__snapshots__/PhpFileTest__exports_attribute_arguments__1.txt @@ -0,0 +1,9 @@ + true, 'four' => null], expression: PHP_VERSION_ID >= 80500)] +class Example {} diff --git a/packages/generation/tests/Php/__snapshots__/PhpFileTest__keeps_multiline_strings_in_method_bodies_intact__1.txt b/packages/generation/tests/Php/__snapshots__/PhpFileTest__keeps_multiline_strings_in_method_bodies_intact__1.txt new file mode 100644 index 0000000000..830bffe8bd --- /dev/null +++ b/packages/generation/tests/Php/__snapshots__/PhpFileTest__keeps_multiline_strings_in_method_bodies_intact__1.txt @@ -0,0 +1,16 @@ +{$title} + HTML; + } +} diff --git a/packages/view/composer.json b/packages/view/composer.json index ee1c114a32..e4cf3ef5ae 100644 --- a/packages/view/composer.json +++ b/packages/view/composer.json @@ -10,7 +10,8 @@ "tempest/validation": "3.x-dev", "tempest/clock": "3.x-dev", "league/commonmark": "^2.7", - "symfony/cache": "^7.3|^8.0" + "symfony/cache": "^7.3|^8.0", + "nette/schema": "^1.3.4" }, "autoload": { "files": [ diff --git a/tests/Benchmark/Generation/ClassGenerationBench.php b/tests/Benchmark/Generation/ClassGenerationBench.php new file mode 100644 index 0000000000..4e882e1f71 --- /dev/null +++ b/tests/Benchmark/Generation/ClassGenerationBench.php @@ -0,0 +1,66 @@ +setFinal(); + $class->setReadOnly(); + $class->addImplement('Tempest\\Database\\MigratesUp'); + $class->addMethod( + 'up', + body: "return new \\Tempest\\Database\\QueryStatements\\CreateTableStatement('books')\n ->primary()\n ->text('title');", + returnType: 'Tempest\\Database\\QueryStatement', + ); + $class->addMethod('getName', body: "return '2026-10-27_create_books_table';", returnType: 'string'); + $class->print(); + } + + #[Iterations(5)] + #[Revs(200)] + #[Warmup(2)] + public function benchManipulateStub(): void + { + $class = new ClassManipulator(__DIR__ . '/../../../packages/auth/src/Installer/oauth/OAuthControllerStub.php'); + $class->setNamespace('App\\Authentication\\OAuth'); + $class->setClassName('GithubController'); + $class->removeClassAttribute(SkipDiscovery::class); + $class->print(); + } + + #[Iterations(5)] + #[Revs(20)] + #[Warmup(1)] + public function benchManipulateLargeClass(): void + { + $class = new ClassManipulator(__DIR__ . '/../../../packages/container/src/GenericContainer.php'); + $class->setNamespace('App\\Container'); + $class->setClassName('Container'); + $class->print(); + } + + #[Iterations(5)] + #[Revs(200)] + #[Warmup(2)] + public function benchDetectStubFileTypes(): void + { + StubFile::from(__DIR__ . '/../../../packages/console/src/Stubs/CommandStub.php'); + StubFile::from(__DIR__ . '/../../../packages/console/src/Stubs/database.config.stub.php'); + } +} diff --git a/tests/Integration/Generation/StubFileGeneratorTest.php b/tests/Integration/Generation/StubFileGeneratorTest.php new file mode 100644 index 0000000000..f0737ae1a9 --- /dev/null +++ b/tests/Integration/Generation/StubFileGeneratorTest.php @@ -0,0 +1,126 @@ +installer->configure( + $this->internalStorage . '/install', + new Psr4Namespace('App\\', $this->internalStorage . '/install/App'), + ); + } + + protected function tearDown(): void + { + $this->installer->clean(); + + parent::tearDown(); + } + + #[Test] + #[DataProvider('production_stubs')] + public function generates_class_files_from_production_stubs(string $stub, array $replacements = [], array $manipulations = []): void + { + $targetPath = $this->installer->path('App/Generated/GeneratedClass.php'); + + $this->container->get(StubFileGenerator::class)->generateClassFile( + stubFile: StubFile::from($stub), + targetPath: $targetPath, + replacements: $replacements, + manipulations: [ + static fn (ClassManipulator $class) => $class->removeClassAttribute(SkipDiscovery::class), + ...$manipulations, + ], + ); + + $code = $this->installer->get('App/Generated/GeneratedClass.php'); + + $this->assertNotEmpty(token_get_all($code, TOKEN_PARSE)); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Snapshots are not supported on Windows.'); + } + + $this->assertMatchesSnapshot($code); + } + + public static function production_stubs(): iterable + { + yield 'command' => [CommandStub::class, ['dummy-command-slug' => 'generated-class']]; + yield 'generator command' => [GeneratorCommandStub::class, ['dummy-command-slug' => 'generated-class']]; + yield 'console middleware' => [ConsoleMiddlewareStub::class]; + yield 'http middleware' => [HttpMiddlewareStub::class]; + yield 'event bus middleware' => [EventBusMiddlewareStub::class]; + yield 'command bus middleware' => [CommandBusMiddlewareStub::class]; + yield 'initializer' => [InitializerStub::class]; + yield 'singleton initializer' => [ + InitializerStub::class, + [], + [static fn (ClassManipulator $class) => $class->addMethodAttribute('initialize', Singleton::class)], + ]; + yield 'discovery' => [DiscoveryStub::class]; + yield 'controller' => [ControllerStub::class, ['dummy-path' => 'books', 'dummy-view' => 'books.view.php']]; + yield 'database model' => [DatabaseModelStub::class]; + yield 'object migration' => [ObjectMigrationStub::class, ['dummy-date' => '2026-10-27', 'dummy-migration-name' => 'create_books_table', 'dummy-table-name' => 'books']]; + yield 'object alter migration' => [ + ObjectAlterMigrationStub::class, + ['dummy-date' => '2026-10-27', 'dummy-migration-name' => 'alter_books_table', 'dummy-table-name' => 'books'], + ]; + yield 'up migration' => [UpMigrationStub::class, ['dummy-date' => '2026-10-27', 'dummy-migration-name' => 'create_books_table', 'dummy-table-name' => 'books']]; + yield 'up alter migration' => [UpAlterMigrationStub::class, ['dummy-date' => '2026-10-27', 'dummy-migration-name' => 'alter_books_table', 'dummy-table-name' => 'books']]; + yield 'request' => [RequestStub::class]; + yield 'response' => [ResponseStub::class]; + yield 'view' => [ViewStub::class, ['dummy.view.php' => 'generated-class.view.php']]; + yield 'users table migration' => [__DIR__ . '/../../../packages/auth/src/Installer/basic-user/CreateUsersTableMigration.stub.php']; + yield 'user model' => [__DIR__ . '/../../../packages/auth/src/Installer/basic-user/UserModel.stub.php']; + yield 'authentication decorator' => [__DIR__ . '/../../../packages/auth/src/Installer/basic-user/MustBeAuthenticated.stub.php']; + yield 'authentication middleware' => [__DIR__ . '/../../../packages/auth/src/Installer/basic-user/MustBeAuthenticatedMiddleware.stub.php']; + yield 'oauth controller' => [OAuthControllerStub::class]; + yield 'commands table migration' => [CreateCommandsTable::class]; + yield 'sessions table migration' => [CreateSessionsTable::class]; + yield 'cleanup sessions command' => [CleanupSessionsCommand::class]; + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication decorator__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication decorator__1.txt new file mode 100644 index 0000000000..f836ecae0f --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication decorator__1.txt @@ -0,0 +1,19 @@ +middleware[] = MustBeAuthenticatedMiddleware::class; + + return $route; + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication middleware__1.txt new file mode 100644 index 0000000000..aabcae43a2 --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication middleware__1.txt @@ -0,0 +1,26 @@ +authenticator->current()) { + return new Forbidden(); + } + + return $next($request); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set cleanup sessions command__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set cleanup sessions command__1.txt new file mode 100644 index 0000000000..b02bb86a44 --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set cleanup sessions command__1.txt @@ -0,0 +1,35 @@ +eventBus->listen($this->onSessionDeleted(...)); + $this->sessionManager->deleteExpiredSessions(); + } + + private function onSessionDeleted(SessionDeleted $event): void + { + $this->console->keyValue((string) $event->id, "DESTROYED"); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set command bus middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set command bus middleware__1.txt new file mode 100644 index 0000000000..68cac1bb4a --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set command bus middleware__1.txt @@ -0,0 +1,18 @@ +console->success('Done!'); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set commands table migration__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set commands table migration__1.txt new file mode 100644 index 0000000000..1333865a93 --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set commands table migration__1.txt @@ -0,0 +1,22 @@ +uuid('id') + ->text('payload') + ->datetime('failed_at', nullable: true); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set console middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set console middleware__1.txt new file mode 100644 index 0000000000..c9fb97fa99 --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set console middleware__1.txt @@ -0,0 +1,23 @@ +implements('MyClass::class')) { + return; + } + + $this->discoveryItems->add($location, $class); + } + + /** + * @mago-expect lint:no-empty-loop + */ + public function apply(): void + { + foreach ($this->discoveryItems as $class) { + // Do something with the discovered class + } + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set event bus middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set event bus middleware__1.txt new file mode 100644 index 0000000000..b57b2bab6d --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set event bus middleware__1.txt @@ -0,0 +1,18 @@ +getSuggestedPath($className); + $targetPath = $this->promptTargetPath($suggestedPath); + $shouldOverride = $this->askForOverride($targetPath); + + $this->stubFileGenerator->generateClassFile( + stubFile: StubFile::from('MyStubClass::class'), + targetPath: $targetPath, + shouldOverride: $shouldOverride, + manipulations: [ + fn (ClassManipulator $class) => $class->removeClassAttribute(SkipDiscovery::class), + ], + ); + + $this->console->writeln(); + $this->console->success(sprintf('File successfully created at .', $targetPath)); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set http middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set http middleware__1.txt new file mode 100644 index 0000000000..0d0c2ddf2d --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set http middleware__1.txt @@ -0,0 +1,20 @@ +oauth->createRedirect(); + } + + #[Get('callback-route')] + public function callback(Request $request): Redirect + { + // TODO: implement, the code below is an example + + $this->oauth->authenticate( + request: $request, + map: fn (OAuthUser $user): Authenticatable => query('user-model-fqcn')->updateOrCreate([ + 'provider_db_column' => $user->id, + ], [ + 'provider_db_column' => $user->id, + 'username' => $user->nickname, + 'email' => $user->email, + ]), + ); + + return new Redirect('/'); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set object alter migration__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set object alter migration__1.txt new file mode 100644 index 0000000000..b44ae1a92f --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set object alter migration__1.txt @@ -0,0 +1,25 @@ +primary() + ->datetime('created_at') + ->datetime('updated_at'); + } + + public function down(): QueryStatement + { + return new DropTableStatement('books'); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set request__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set request__1.txt new file mode 100644 index 0000000000..910d9caa4c --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set request__1.txt @@ -0,0 +1,17 @@ +setStatus(Status::OK); + $this->addHeader('Content-Type', 'application/json'); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set sessions table migration__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set sessions table migration__1.txt new file mode 100644 index 0000000000..e884c71e04 --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set sessions table migration__1.txt @@ -0,0 +1,23 @@ +uuid('id') + ->datetime('created_at') + ->datetime('last_active_at') + ->text('data'); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set singleton initializer__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set singleton initializer__1.txt new file mode 100644 index 0000000000..f9eeaf7fd1 --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set singleton initializer__1.txt @@ -0,0 +1,18 @@ +primary() + ->datetime('created_at') + ->datetime('updated_at'); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set user model__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set user model__1.txt new file mode 100644 index 0000000000..db6089034c --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set user model__1.txt @@ -0,0 +1,20 @@ +primary() + ->string('email') + ->string('password', nullable: true); + } +} diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set view__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set view__1.txt new file mode 100644 index 0000000000..369d72beea --- /dev/null +++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set view__1.txt @@ -0,0 +1,18 @@ +path = __DIR__ . '/generated-class.view.php'; + } +}