From 96fb9ff9f6dd5739174fa97c1a636e6d173e881b Mon Sep 17 00:00:00 2001 From: simivar Date: Wed, 13 Nov 2024 23:09:47 +0100 Subject: [PATCH 1/3] bc-break feat: implement EnumGenerator using nikic/PHP-parser This commit introduces a new and custom EnumGenerator to address the limitations we faced with the immutable Laminas' EnumGenerator Instead of relying on the Laminas' generator, we've now built our own generator using the nikic/PHP-Parser package. By making this switch, we've successfully removed the need for reflection to modify properties - something we had to do with the old EnumGenerator. Moreover, with the provided user-friendly function applied to the `nodes`, end-users can add virtually any PHP statement that the `nikic/php-parser` package supports If this solution is accepted we can do the same changes for the rest of hooks and generators --- composer.json | 3 +- src/Codegen/AbstractGenerator.php | 103 ++++++++++++++++++ src/Codegen/EnumGenerator.php | 82 ++++++++++++++ src/Generator/GeneratorHookRunner.php | 2 +- src/Generator/Hook/EnumCreatedHook.php | 2 +- src/Generator/SchemaToClass.php | 1 - src/Generator/SchemaToEnum.php | 26 ++--- tests/Generator/Fixtures/Enum/Output/Foo.php | 5 +- .../Fixtures/EnumConsistent/Output/Foo.php | 5 +- .../Fixtures/EnumHook/Output/Foo.php | 14 +++ tests/Generator/Fixtures/EnumHook/schema.json | 4 + tests/Generator/SchemaToClassTest.php | 19 ++++ 12 files changed, 245 insertions(+), 21 deletions(-) create mode 100644 src/Codegen/AbstractGenerator.php create mode 100644 src/Codegen/EnumGenerator.php create mode 100644 tests/Generator/Fixtures/EnumHook/Output/Foo.php create mode 100644 tests/Generator/Fixtures/EnumHook/schema.json diff --git a/composer.json b/composer.json index acdb6872..f378a372 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "justinrainbow/json-schema": "^6.0", "ext-json": "*", "composer/semver": "^3.0", - "laminas/laminas-code": "^4.12" + "laminas/laminas-code": "^4.12", + "nikic/php-parser": "^4.19" }, "autoload": { "psr-4": { diff --git a/src/Codegen/AbstractGenerator.php b/src/Codegen/AbstractGenerator.php new file mode 100644 index 00000000..8c3c34d9 --- /dev/null +++ b/src/Codegen/AbstractGenerator.php @@ -0,0 +1,103 @@ +nodes, $index, 0, $node); + + return $this; + } + + public function set(int $index, $value): self + { + if (!$this->isValidIndex($index)) { + throw new \OutOfRangeException(); + } + + $this->nodes[$index] = $value; + + return $this; + } + + public function remove(int $index): Node + { + if (!$this->isValidIndex($index)) { + throw new \OutOfRangeException(); + } + + return array_splice($this->nodes, $index, 1, null)[0]; + } + + public function clear(): self + { + $this->nodes = []; + + return $this; + } + + /** + * @psalm-param callable(Node): Node $callback + */ + public function apply(callable $callback): self + { + foreach ($this->nodes as &$value) { + $value = $callback($value); + } + + return $this; + } + + public function filter(callable $callback = null): self + { + return new static(array_filter($this->nodes, $callback ?: 'boolval')); + } + + public function find(Node $value): ?int + { + $offset = array_search($value, $this->nodes, true); + + return $offset === false ? null : $offset; + } + + public function first(): Node + { + if (empty($this->nodes)) { + throw new \UnderflowException(); + } + + return $this->nodes[0]; + } + + public function get(int $index): Node + { + if (!$this->isValidIndex($index)) { + throw new \OutOfRangeException(); + } + + return $this->nodes[$index]; + } + + public function last(): Node + { + return $this->nodes[array_key_last($this->nodes)]; + } + + protected function isValidIndex(int $index): bool + { + return array_key_exists($index, $this->nodes); + } +} diff --git a/src/Codegen/EnumGenerator.php b/src/Codegen/EnumGenerator.php new file mode 100644 index 00000000..bad758f7 --- /dev/null +++ b/src/Codegen/EnumGenerator.php @@ -0,0 +1,82 @@ + */ + protected array $cases = []; + + public function __construct( + protected Enum_ $enum_, + protected Namespace_ $namespace_ + ) + { + parent::__construct([ + $this->buildStrictTypes(), + new Nop(), + $this->namespace_, + $this->enum_, + ]); + } + + public function withEnum_(Enum_ $enum_): self + { + foreach ($this->cases as $name => $case) { + $enum_->stmts[$name] = $case; + } + + $currentEnumIndex = $this->find($this->enum_); + if ($currentEnumIndex !== null) { + $this->set($currentEnumIndex, $enum_); + } + + $this->enum_ = $enum_; + + return $this; + } + + public function withNamespace_(Namespace_ $namespace_): self + { + $currentNamespaceIndex = $this->find($this->namespace_); + if ($currentNamespaceIndex !== null) { + $this->set($currentNamespaceIndex, $namespace_); + } + + $this->namespace_ = $namespace_; + + return $this; + } + + public function withAdditionalEnumCase(EnumCase $enumCase): self + { + $this->cases[$enumCase->name->toLowerString()] = $enumCase; + $this->enum_->stmts[$enumCase->name->toString()] = $enumCase; + + return $this; + } + + protected function buildStrictTypes(): Declare_ + { + $declareDeclare = new DeclareDeclare(new Identifier('strict_types'), new LNumber(1)); + return new Declare_([$declareDeclare]); + } + + public function generate(): string + { + $prettyPrinter = new Standard(); + return $prettyPrinter->prettyPrint($this->nodes); + } +} diff --git a/src/Generator/GeneratorHookRunner.php b/src/Generator/GeneratorHookRunner.php index 0c4309a3..6185d0a0 100644 --- a/src/Generator/GeneratorHookRunner.php +++ b/src/Generator/GeneratorHookRunner.php @@ -2,8 +2,8 @@ namespace Helmich\Schema2Class\Generator; +use Helmich\Schema2Class\Codegen\EnumGenerator; use Laminas\Code\Generator\ClassGenerator; -use Laminas\Code\Generator\EnumGenerator\EnumGenerator; use Laminas\Code\Generator\FileGenerator; trait GeneratorHookRunner diff --git a/src/Generator/Hook/EnumCreatedHook.php b/src/Generator/Hook/EnumCreatedHook.php index 684cf7f8..4ad17d59 100644 --- a/src/Generator/Hook/EnumCreatedHook.php +++ b/src/Generator/Hook/EnumCreatedHook.php @@ -2,7 +2,7 @@ namespace Helmich\Schema2Class\Generator\Hook; -use Laminas\Code\Generator\EnumGenerator\EnumGenerator; +use Helmich\Schema2Class\Codegen\EnumGenerator; /** * Interface definition for hooks that are called when an enumeration is created. diff --git a/src/Generator/SchemaToClass.php b/src/Generator/SchemaToClass.php index 4e8eed51..feb65a92 100644 --- a/src/Generator/SchemaToClass.php +++ b/src/Generator/SchemaToClass.php @@ -12,7 +12,6 @@ use Laminas\Code\Generator\ClassGenerator; use Laminas\Code\Generator\DocBlock\Tag\GenericTag; use Laminas\Code\Generator\DocBlockGenerator; -use Laminas\Code\Generator\EnumGenerator\EnumGenerator; use Laminas\Code\Generator\FileGenerator; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Generator/SchemaToEnum.php b/src/Generator/SchemaToEnum.php index 12c06c02..471ee28d 100644 --- a/src/Generator/SchemaToEnum.php +++ b/src/Generator/SchemaToEnum.php @@ -2,10 +2,12 @@ namespace Helmich\Schema2Class\Generator; +use Helmich\Schema2Class\Codegen\EnumGenerator; use Helmich\Schema2Class\Writer\WriterInterface; -use Laminas\Code\DeclareStatement; -use Laminas\Code\Generator\EnumGenerator\EnumGenerator; use Laminas\Code\Generator\FileGenerator; +use PhpParser\Builder\Enum_; +use PhpParser\Builder\EnumCase; +use PhpParser\Builder\Namespace_; class SchemaToEnum { @@ -39,24 +41,22 @@ public function schemaToEnum(GeneratorRequest $req): void $type = $req->getSchema()["type"] === "string" ? "string" : "int"; $enumName = $req->getTargetNamespace() . "\\" . $req->getTargetClass(); - $enum = EnumGenerator::withConfig([ - "name" => $enumName, - "backedCases" => [ - "type" => $type, - "cases" => $cases, - ], - ]); + $enumGenerator = new EnumGenerator( + enum_: (new Enum_($req->getTargetClass()))->setScalarType($type)->getNode(), + namespace_: (new Namespace_($req->getTargetNamespace()))->getNode(), + ); + foreach ($cases as $name => $value) { + $enumGenerator->withAdditionalEnumCase((new EnumCase($name))->setValue($value)->getNode()); + } - $req->onEnumCreated($enumName, $enum); + $req->onEnumCreated($enumName, $enumGenerator); $filename = $req->getTargetDirectory() . '/' . $req->getTargetClass() . '.php'; $file = new FileGenerator(); - $file->setBody($enum->generate()); + $file->setBody($enumGenerator->generate()); $req->onFileCreated($filename, $file); - $file->setDeclares([DeclareStatement::strictTypes(1)]); - $content = $file->generate(); // Do some corrections because the Zend code generation library is stupid. diff --git a/tests/Generator/Fixtures/Enum/Output/Foo.php b/tests/Generator/Fixtures/Enum/Output/Foo.php index c4376336..cd9df2bf 100644 --- a/tests/Generator/Fixtures/Enum/Output/Foo.php +++ b/tests/Generator/Fixtures/Enum/Output/Foo.php @@ -1,10 +1,11 @@ withHook(new class () implements EnumCreatedHook { + function onEnumCreated(string $enumName, EnumGenerator $enum): void + { + if ($enumName !== 'Ns\EnumHook\Foo') { + return; + } + + $enum->withAdditionalEnumCase((new EnumCase('blue'))->setValue('blue')->getNode()); + $enum->withEnum_((new Enum_('Foo')) + ->setScalarType('string') + ->setDocComment('/** Doc comment */') + ->getNode() + ); + } + }); $req = $req->withReferenceLookup(new class ($schema) implements ReferenceLookup { public function __construct(private readonly array $schema) { From be03411852f67b91e35ac92b7444535ef069c72a Mon Sep 17 00:00:00 2001 From: simivar Date: Thu, 14 Nov 2024 00:00:55 +0100 Subject: [PATCH 2/3] fix psalm issues --- src/Codegen/AbstractGenerator.php | 31 +++++++--- src/Codegen/EnumGenerator.php | 2 +- tests/Codegen/AbstractGeneratorTest.php | 78 +++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 tests/Codegen/AbstractGeneratorTest.php diff --git a/src/Codegen/AbstractGenerator.php b/src/Codegen/AbstractGenerator.php index 8c3c34d9..816b9551 100644 --- a/src/Codegen/AbstractGenerator.php +++ b/src/Codegen/AbstractGenerator.php @@ -17,7 +17,7 @@ public function __construct(protected array $nodes) public function insert(int $index, Node $node): self { - array_splice($this->nodes, $index, 0, $node); + array_splice($this->nodes, $index, 0, [$node]); return $this; } @@ -39,7 +39,7 @@ public function remove(int $index): Node throw new \OutOfRangeException(); } - return array_splice($this->nodes, $index, 1, null)[0]; + return array_splice($this->nodes, $index, 1)[0]; } public function clear(): self @@ -52,18 +52,22 @@ public function clear(): self /** * @psalm-param callable(Node): Node $callback */ - public function apply(callable $callback): self + public function walk(callable $callback): self { - foreach ($this->nodes as &$value) { - $value = $callback($value); - } + array_walk($this->nodes, $callback); return $this; } - public function filter(callable $callback = null): self + /** + * @psalm-param ($mode is 0 ? callable(Node) : callable(Node, int) ) $callback + */ + public function filter(callable $callback, int $mode = 0): self { - return new static(array_filter($this->nodes, $callback ?: 'boolval')); + $this->nodes = array_filter($this->nodes, $callback, $mode); + $this->nodes = array_values($this->nodes); + + return $this; } public function find(Node $value): ?int @@ -91,11 +95,20 @@ public function get(int $index): Node return $this->nodes[$index]; } - public function last(): Node + public function last(): ?Node { + if (empty($this->nodes)) { + return null; + } + return $this->nodes[array_key_last($this->nodes)]; } + public function count(): int + { + return count($this->nodes); + } + protected function isValidIndex(int $index): bool { return array_key_exists($index, $this->nodes); diff --git a/src/Codegen/EnumGenerator.php b/src/Codegen/EnumGenerator.php index bad758f7..914aaf5c 100644 --- a/src/Codegen/EnumGenerator.php +++ b/src/Codegen/EnumGenerator.php @@ -16,7 +16,7 @@ class EnumGenerator extends AbstractGenerator { - /** @var array */ + /** @var array */ protected array $cases = []; public function __construct( diff --git a/tests/Codegen/AbstractGeneratorTest.php b/tests/Codegen/AbstractGeneratorTest.php new file mode 100644 index 00000000..7e56b51a --- /dev/null +++ b/tests/Codegen/AbstractGeneratorTest.php @@ -0,0 +1,78 @@ +getNode(); + $secondNode = (new Use_('TestImport', Node\Stmt\Use_::TYPE_NORMAL))->getNode(); + $thirdNode = (new Enum_('TestEnum'))->getNode(); + $setNode = (new Enum_('SetEnum'))->getNode(); + + $generator = new class([$firstNode, $secondNode, $thirdNode]) extends AbstractGenerator {}; + $removed = $generator->remove(1); + $generator->set(1, $setNode); + + $this->assertSame($secondNode, $removed); + $this->assertSame(2, $generator->count()); + $this->assertSame($firstNode, $generator->first()); + $this->assertSame($firstNode, $generator->get(0)); + $this->assertSame($setNode, $generator->last()); + } + + public function test_insert(): void + { + $firstNode = (new Namespace_('TestNamespaceInsert'))->getNode(); + $secondNode = (new Use_('TestImportInsert', Node\Stmt\Use_::TYPE_NORMAL))->getNode(); + $insertNode = (new Enum_('InsertedEnum'))->getNode(); + + $generator = new class([$firstNode, $secondNode]) extends AbstractGenerator {}; + $generator->insert(1, $insertNode); + + $this->assertSame(3, $generator->count()); + $this->assertSame($firstNode, $generator->first()); + $this->assertSame($insertNode, $generator->get(1)); + $this->assertSame($secondNode, $generator->last()); + } + + public function test_filter(): void + { + $firstNode = (new Namespace_('TestNamespaceInsert'))->getNode(); + $secondNode = (new Use_('TestImportInsert', Node\Stmt\Use_::TYPE_NORMAL))->getNode(); + $thirdNode = (new Enum_('InsertedEnum'))->getNode(); + + $generator = new class([$firstNode, $secondNode, $thirdNode]) extends AbstractGenerator {}; + $generator->filter(function (Node $value) { + return $value instanceof Node\Stmt\Enum_; + }); + + $this->assertSame(1, $generator->count()); + $this->assertSame($thirdNode, $generator->first()); + } + + public function test_walk(): void + { + $firstNode = (new Namespace_('TestNamespaceInsert'))->getNode(); + $secondNode = (new Use_('TestImportInsert', Node\Stmt\Use_::TYPE_NORMAL))->getNode(); + $replaceNode = (new Enum_('InsertedEnum'))->getNode(); + + $generator = new class([$firstNode, $secondNode]) extends AbstractGenerator {}; + $generator->walk(function (Node &$value) use ($replaceNode) { + $value = $replaceNode; + }); + + $this->assertSame(2, $generator->count()); + $this->assertSame($replaceNode, $generator->first()); + $this->assertSame($replaceNode, $generator->last()); + } +} \ No newline at end of file From 767e4dd93d4d03545bb5b1c63a9b2c467d943d48 Mon Sep 17 00:00:00 2001 From: simivar Date: Thu, 14 Nov 2024 00:06:26 +0100 Subject: [PATCH 3/3] make last throw exception instead of returning null --- src/Codegen/AbstractGenerator.php | 4 ++-- tests/Codegen/AbstractGeneratorTest.php | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Codegen/AbstractGenerator.php b/src/Codegen/AbstractGenerator.php index 816b9551..cd7505bd 100644 --- a/src/Codegen/AbstractGenerator.php +++ b/src/Codegen/AbstractGenerator.php @@ -95,10 +95,10 @@ public function get(int $index): Node return $this->nodes[$index]; } - public function last(): ?Node + public function last(): Node { if (empty($this->nodes)) { - return null; + throw new \UnderflowException(); } return $this->nodes[array_key_last($this->nodes)]; diff --git a/tests/Codegen/AbstractGeneratorTest.php b/tests/Codegen/AbstractGeneratorTest.php index 7e56b51a..f550a655 100644 --- a/tests/Codegen/AbstractGeneratorTest.php +++ b/tests/Codegen/AbstractGeneratorTest.php @@ -12,6 +12,24 @@ final class AbstractGeneratorTest extends TestCase { + public function test_last_throws_exception_on_empty_nodes(): void + { + $generator = new class([]) extends AbstractGenerator {}; + + $this->expectException(\UnderflowException::class); + + $generator->last(); + } + + public function test_first_throws_exception_on_empty_nodes(): void + { + $generator = new class([]) extends AbstractGenerator {}; + + $this->expectException(\UnderflowException::class); + + $generator->last(); + } + public function test_remove_and_set(): void { $firstNode = (new Namespace_('TestNamespace'))->getNode();