diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 9de0854cc5..ea231fba98 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -224,7 +224,7 @@ final readonly class Client } ``` -Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will only ever be resolved once: +Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will be reused for the singleton’s lifetime: ```php app/MarkdownInitializer.php use Tempest\Console\ConsoleCommand; @@ -241,6 +241,55 @@ final readonly class MarkdownInitializer implements Initializer } ``` +### Singleton lifetimes + +By default, singletons use {`Tempest\Container\Lifetime::PROCESS`} as their lifetime: the container keeps the same instance for the lifetime of the process, including across requests in long-running applications. + +For objects that hold request-specific state, you can specify `Lifetime::REQUEST` instead: + +```php +use Tempest\Container\Lifetime; +use Tempest\Container\Singleton; + +#[Singleton(lifetime: Lifetime::REQUEST)] +final class RequestContext +{ + public ?string $tenantId = null; +} +``` + +The container reuses this instance within a request. When the container is reset, it clears the instance so that the next request receives a fresh one, this happens after every request in long-running applications. + +You can also set the lifetime when registering a singleton with the container. Use a factory for request-lifetime singletons so the container can create a fresh instance after each reset: + +```php +use Tempest\Container\Lifetime; + +$container->singleton( + RequestContext::class, + fn () => new RequestContext(), + lifetime: Lifetime::REQUEST, +); +``` + +Note that you can also declare the lifetime via initializers: + +```php +use Tempest\Container\Container; +use Tempest\Container\Initializer; +use Tempest\Container\Lifetime; +use Tempest\Container\Singleton; + +final class RequestContextInitializer implements Initializer +{ + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(Container $container): RequestContext + { + return new RequestContext(); + } +} +``` + ### Tagged singletons In some cases, you want more control over singleton definitions. diff --git a/packages/container/src/Container.php b/packages/container/src/Container.php index dba11e198a..7331cc9408 100644 --- a/packages/container/src/Container.php +++ b/packages/container/src/Container.php @@ -16,7 +16,7 @@ public function register(string $className, callable $definition): self; public function unregister(string $className, bool $tagged = false): self; - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self; + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self; public function config(object $config): self; diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 8901e4900b..682372ec4a 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -21,6 +21,7 @@ use Tempest\Reflection\TypeReflector; use Throwable; use UnitEnum; +use WeakMap; use const ARRAY_FILTER_USE_BOTH; @@ -31,6 +32,12 @@ final class GenericContainer implements Container /** @var array, DynamicInitializer> */ private array $resolvedDynamicInitializers = []; + /** @var WeakMap */ + private WeakMap $singletonLifetimes; + + /** @var array */ + private array $registeredSingletonLifetimes = []; + public function __construct( /** @var ArrayIterator $definitions */ private(set) ArrayIterator $definitions = new ArrayIterator(), @@ -55,6 +62,7 @@ public function __construct( private(set) ?DependencyChain $chain = null, ) { + $this->singletonLifetimes = new WeakMap(); $this->singleton(Container::class, $this); $this->singleton(ContainerInterface::class, $this); $this->singleton(GenericContainer::class, $this); @@ -146,6 +154,7 @@ public function unregister(string $className, bool $tagged = false): self unset($this->definitions[$className]); unset($this->singletonDefinitions[$className]); unset($this->resolvedSingletons[$className]); + unset($this->registeredSingletonLifetimes[$className]); if ($tagged) { foreach ($this->singletonDefinitions as $key => $definition) { @@ -154,6 +163,7 @@ public function unregister(string $className, bool $tagged = false): self } unset($this->singletonDefinitions[$key]); + unset($this->registeredSingletonLifetimes[$key]); } foreach ($this->resolvedSingletons as $key => $definition) { @@ -173,7 +183,7 @@ public function has(string $className, string|UnitEnum|null $tag = null): bool return isset($this->definitions[$className]) || isset($this->singletonDefinitions[$this->resolveTaggedName($className, $tag)]); } - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self { if ($definition instanceof HasTag) { $tag = $definition->tag; @@ -181,6 +191,16 @@ public function singleton(string $className, mixed $definition, string|UnitEnum| $dependencyName = $this->resolveTaggedName($className, $tag); + if ($lifetime instanceof Lifetime) { + $this->registeredSingletonLifetimes[$dependencyName] = $lifetime; + + if (is_object($definition) && ! $definition instanceof Closure) { + $this->singletonLifetimes[$definition] = $lifetime; + } + } else { + unset($this->registeredSingletonLifetimes[$dependencyName]); + } + $this->singletonDefinitions[$dependencyName] = $definition; unset($this->resolvedSingletons[$dependencyName]); @@ -393,12 +413,21 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag // Check if the class has been registered as a singleton. if ($singletonDefinition = $this->singletonDefinitions[$dependencyName] ?? null) { - $this->resolveChain()->add($class); + // Only closures need to be tracked: an already constructed instance cannot take part in a cycle. + if ($singletonDefinition instanceof Closure) { + $this->resolveChain()->add($class); + + try { + $instance = $singletonDefinition($this); + } finally { + $this->resolveChain()->pop(); + } + } else { + $instance = $singletonDefinition; + } - try { - $instance = $singletonDefinition instanceof Closure ? $singletonDefinition($this) : $singletonDefinition; - } finally { - $this->resolveChain()->pop(); + if (isset($this->registeredSingletonLifetimes[$dependencyName])) { + $this->singletonLifetimes[$instance] = $this->registeredSingletonLifetimes[$dependencyName]; } $this->resolvedSingletons[$dependencyName] = $instance; @@ -432,9 +461,12 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $this->resolveChain()->pop(); } - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class); + $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { + $this->singletonLifetimes[$object] = $singleton->lifetime; $this->singleton($className, $object, $tag); } @@ -531,8 +563,9 @@ private function autowire(string $className, string|UnitEnum|null $tag, mixed .. if ( ! $classReflector->getType()->matches(Initializer::class) && ! $classReflector->getType()->matches(DynamicInitializer::class) - && $classReflector->hasAttribute(Singleton::class) + && ($singleton = $classReflector->getAttribute(Singleton::class)) !== null ) { + $this->singletonLifetimes[$instance] = $singleton->lifetime; $this->singleton($className, $instance, $tag); } @@ -776,7 +809,6 @@ public function addResettable(string|ClassReflector $resettableClass): Container public function reset(): self { - $this->resolvedSingletons = new ArrayIterator(); $this->resolvedDynamicInitializers = []; foreach ($this->resettables as $resettableClass) { @@ -786,6 +818,25 @@ public function reset(): self $resettable->reset(); } + foreach ([$this->singletonDefinitions, $this->resolvedSingletons] as $singletons) { + foreach ($singletons->getArrayCopy() as $dependencyName => $instance) { + // Factories remain registered so the next request can create a fresh instance. + if (! is_object($instance)) { + continue; + } + + if ($instance instanceof Closure) { + continue; + } + + $this->singletonLifetimes[$instance] ??= new ClassReflector($instance)->getAttribute(Singleton::class)->lifetime ?? Lifetime::PROCESS; + + if ($this->singletonLifetimes[$instance] === Lifetime::REQUEST) { + unset($singletons[$dependencyName]); + } + } + } + return $this; } diff --git a/packages/container/src/Lifetime.php b/packages/container/src/Lifetime.php new file mode 100644 index 0000000000..d07dbc34bd --- /dev/null +++ b/packages/container/src/Lifetime.php @@ -0,0 +1,14 @@ +get(ProcessLifetimeSingleton::class); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeSingleton::class); + $tagged = $container->get(RequestLifetimeSingleton::class, 'tag'); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($tagged, $container->get(RequestLifetimeSingleton::class, 'tag')); + $this->assertNotSame($instance, $tagged); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertNotSame($tagged, $container->get(RequestLifetimeSingleton::class, 'tag')); + $this->assertSame($process, $container->get(ProcessLifetimeSingleton::class)); + } + } + + #[Test] + public function lifetimes_are_tracked_when_resolving_nested_dependencies(): void + { + $container = new GenericContainer(); + $instance = $container->get(RequestLifetimeDependency::class)->singleton; + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeDependency::class)->singleton); + } + + #[Test] + public function tagged_initializer_singletons_are_cleared(): void + { + $container = new GenericContainer(); + $container->addInitializer(RequestLifetimeInitializer::class); + + $instance = $container->get(RequestLifetimeInterface::class, 'tag'); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); + } + + #[Test] + public function registered_factories_follow_their_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, fn () => new SingletonClass()); + $container->singleton(RequestLifetimeInterface::class, fn () => new RequestLifetimeSingleton(), 'request'); + + $process = $container->get(SingletonClass::class); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class, 'request'); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'request')); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class, 'request')); + $this->assertSame($process, $container->get(SingletonClass::class)); + } + } + + #[Test] + public function registered_factory_can_specify_a_request_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, fn () => new SingletonClass(), lifetime: Lifetime::REQUEST); + + $instance = $container->get(SingletonClass::class); + $this->assertSame($instance, $container->get(SingletonClass::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(SingletonClass::class)); + } + + #[Test] + public function registered_instance_can_override_its_attribute_lifetime(): void + { + $container = new GenericContainer(); + $instance = new RequestLifetimeSingleton(); + $container->singleton(RequestLifetimeInterface::class, $instance, lifetime: Lifetime::PROCESS); + + $container->reset(); + + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + } + + #[Test] + public function registered_instance_can_specify_a_request_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, new SingletonClass(), lifetime: Lifetime::REQUEST); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class)); + } + + #[Test] + public function process_initializer_singletons_survive_reset(): void + { + $container = new GenericContainer(); + $container->addInitializer(SingletonInitializer::class); + + $instance = $container->get(ContainerObjectE::class); + + $container->reset(); + + $this->assertSame($instance, $container->get(ContainerObjectE::class)); + } + + #[Test] + public function initialized_classes_can_declare_their_own_lifetime(): void + { + $initializer = new class implements Initializer { + public function initialize(Container $container): RequestLifetimeSingleton + { + return new RequestLifetimeSingleton(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + + $instance = $container->get(RequestLifetimeSingleton::class); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + } + + #[Test] + public function registered_request_instances_are_removed(): void + { + $container = new GenericContainer(); + $container->singleton(RequestLifetimeSingleton::class, $instance = new RequestLifetimeSingleton()); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertFalse($container->has(RequestLifetimeSingleton::class)); + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + } + + #[Test] + public function initializer_lifetime_is_shared_by_all_bindings_of_the_instance(): void + { + $initializer = new class implements Initializer { + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(Container $container): SingletonClass + { + return new SingletonClass(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + $container->singleton(SingletonClass::class, fn (Container $container) => $container->get(SingletonClass::class), 'alias'); + + for ($request = 0; $request < 3; $request++) { + // Parameter resolution uses an internal container clone. + $instance = $container->invoke(fn (SingletonClass $instance) => $instance); + $container->singleton(SingletonClass::class, $instance, 'instance'); + $this->assertSame($instance, $container->get(SingletonClass::class, 'alias')); + $this->assertSame($instance, $container->get(SingletonClass::class, 'instance')); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class, 'instance')); + $next = $container->get(SingletonClass::class, 'alias'); + $this->assertNotSame($instance, $next); + $this->assertSame($next, $container->get(SingletonClass::class)); + } + } + + #[Test] + public function initializer_lifetime_takes_precedence_over_the_class_lifetime(): void + { + $initializer = new class implements Initializer { + #[Singleton(lifetime: Lifetime::PROCESS)] + public function initialize(Container $container): RequestLifetimeSingleton + { + return new RequestLifetimeSingleton(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + + $instance = $container->get(RequestLifetimeSingleton::class); + $container->singleton(RequestLifetimeInterface::class, $instance); + + $container->reset(); + + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + } + + #[Test] + public function replacing_a_request_singleton_updates_its_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, new RequestLifetimeSingleton()); + $container->get(SingletonClass::class); + $container->singleton(SingletonClass::class, $process = new SingletonClass()); + + $container->reset(); + + $this->assertSame($process, $container->get(SingletonClass::class)); + } +} diff --git a/packages/container/tests/ContainerTest.php b/packages/container/tests/ContainerTest.php index b37f7e8e6b..7b833f7b34 100644 --- a/packages/container/tests/ContainerTest.php +++ b/packages/container/tests/ContainerTest.php @@ -860,7 +860,7 @@ public function reset(): void $container->get(SingletonClass::class); $container->get(SingletonClass::class); - $this->assertSame(2, SingletonClass::$count); // constructed twice, once before and once after reset + $this->assertSame(1, SingletonClass::$count); $this->assertTrue(ResettableDependency::$reset); } } diff --git a/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php b/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php new file mode 100644 index 0000000000..a447a0c3c7 --- /dev/null +++ b/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php @@ -0,0 +1,10 @@ + $arguments */ - public static function add(Stmt\Class_|Stmt\ClassMethod|Param $node, string $name, array $arguments = []): void + public static function add(Class_|ClassMethod|Param $node, string $name, array $arguments = []): void { $node->attrGroups[] = new AttributeGroup([ - new Attribute(new Name\FullyQualified(ltrim($name, '\\')), ValueExporter::exportArguments($arguments)), + new Attribute(new FullyQualified(ltrim($name, '\\')), ValueExporter::exportArguments($arguments)), ]); } - public static function remove(Stmt\Class_|Stmt\ClassMethod|Param $node, string $name): void + public static function remove(Class_|ClassMethod|Param $node, string $name): void { $groups = []; diff --git a/packages/generation/src/Php/Changes.php b/packages/generation/src/Php/Changes.php index 1eaa735ead..ebdeaf781c 100644 --- a/packages/generation/src/Php/Changes.php +++ b/packages/generation/src/Php/Changes.php @@ -5,7 +5,17 @@ namespace Tempest\Generation\Php; use PhpParser\Node; +use PhpParser\Node\AttributeGroup; +use PhpParser\Node\DeclareItem; +use PhpParser\Node\Param; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Declare_; +use PhpParser\Node\Stmt\GroupUse; +use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\Stmt\Use_; +use PhpParser\Node\UseItem; /** * Tells which nodes of loaded code are identical to the node they were cloned from, @@ -22,16 +32,16 @@ final class Changes * so any other node can only change when a name inside it is replaced, which marks it as changed. */ private const array MUTABLE_NODES = [ - Stmt\Namespace_::class => 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, + Namespace_::class => true, + Use_::class => true, + GroupUse::class => true, + Class_::class => true, + ClassMethod::class => true, + Declare_::class => true, + DeclareItem::class => true, + AttributeGroup::class => true, + Param::class => true, + UseItem::class => true, ]; /** diff --git a/packages/generation/src/Php/Cloner.php b/packages/generation/src/Php/Cloner.php index a61f8e8c39..c28e4d68e8 100644 --- a/packages/generation/src/Php/Cloner.php +++ b/packages/generation/src/Php/Cloner.php @@ -6,8 +6,15 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\GroupUse; +use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\Stmt\Use_; +use PhpParser\Node\UseItem; /** * Clones parsed code so it can be changed and printed format-preserving, resolving names @@ -34,7 +41,7 @@ final class Cloner public function cloneStatements(array $stmts): array { /** @var Stmt[] */ - return $this->cloneValue($stmts, Stmt\Use_::TYPE_NORMAL); + return $this->cloneValue($stmts, Use_::TYPE_NORMAL); } private function cloneValue(mixed $value, int $nameType): mixed @@ -65,10 +72,10 @@ private function cloneNode(Node $node, int $nameType): Node return $clone; } - if ($node instanceof Stmt\Namespace_) { + if ($node instanceof Namespace_) { $this->namespace = $node->name?->toString(); $this->imports = []; - } elseif ($node instanceof Stmt\Use_ || $node instanceof Stmt\GroupUse) { + } elseif ($node instanceof Use_ || $node instanceof GroupUse) { $this->import($node); } @@ -87,20 +94,20 @@ private function cloneNode(Node $node, int $nameType): 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, + $node instanceof Namespace_, $node instanceof Use_, $node instanceof GroupUse, $node instanceof UseItem => self::UNRESOLVED, + $node instanceof FuncCall => Use_::TYPE_FUNCTION, + $node instanceof ConstFetch => Use_::TYPE_CONSTANT, + default => Use_::TYPE_NORMAL, }; } - private function import(Stmt\Use_|Stmt\GroupUse $node): void + private function import(Use_|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; + $type = $node->type !== Use_::TYPE_UNKNOWN ? $node->type : $item->type; + $name = $node instanceof GroupUse ? Name::concat($node->prefix, $item->name) : $item->name; - $this->imports[$type][self::aliasKey($type, $item->getAlias()->toString())] = $name->toString(); + $this->imports[$type][$this->aliasKey($type, $item->getAlias()->toString())] = $name->toString(); } } @@ -122,10 +129,10 @@ private function resolve(Name $name, int $type): void 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); + } elseif ($type !== Use_::TYPE_NORMAL && $name->isUnqualified()) { + $resolved = $this->imports[$type][$this->aliasKey($type, $name->toString())] ?? $this->resolveGlobally($name, $type); } else { - $import = $this->imports[Stmt\Use_::TYPE_NORMAL][strtolower($name->getFirst())] ?? null; + $import = $this->imports[Use_::TYPE_NORMAL][strtolower($name->getFirst())] ?? null; $resolved = match (true) { $import === null => $this->prefixNamespace($name->toString()), $name->isQualified() => $import . '\\' . $name->slice(1)?->toString(), @@ -134,7 +141,7 @@ private function resolve(Name $name, int $type): void } if ($resolved !== null) { - $name->setAttribute(Names::RESOLVED_NAME_ATTRIBUTE, new Name\FullyQualified($resolved)); + $name->setAttribute(Names::RESOLVED_NAME_ATTRIBUTE, new FullyQualified($resolved)); } } @@ -148,7 +155,7 @@ private function resolveGlobally(Name $name, int $type): ?string return null; } - if ($type === Stmt\Use_::TYPE_CONSTANT && in_array($name->toLowerString(), ['true', 'false', 'null'], strict: true)) { + if ($type === Use_::TYPE_CONSTANT && in_array($name->toLowerString(), ['true', 'false', 'null'], strict: true)) { return null; } @@ -160,8 +167,8 @@ private function prefixNamespace(string $name): string return $this->namespace === null ? $name : $this->namespace . '\\' . $name; } - private static function aliasKey(int $type, string $alias): string + private function aliasKey(int $type, string $alias): string { - return $type === Stmt\Use_::TYPE_CONSTANT ? $alias : strtolower($alias); + return $type === 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 c391aa4406..a6739786ed 100644 --- a/packages/generation/src/Php/DataObjects/StubFile.php +++ b/packages/generation/src/Php/DataObjects/StubFile.php @@ -6,6 +6,8 @@ use InvalidArgumentException; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\Namespace_; use ReflectionClass; use Tempest\Generation\Php\Exceptions\PhpCodeWasInvalid; use Tempest\Generation\Php\SourceParser; @@ -57,7 +59,7 @@ private static function declaresClass(string $path): bool } foreach ($stmts as $stmt) { - if (! $stmt instanceof Stmt\Namespace_) { + if (! $stmt instanceof Namespace_) { continue; } @@ -66,6 +68,6 @@ private static function declaresClass(string $path): bool break; } - return array_any($stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\Class_); + return array_any($stmts, static fn (Stmt $stmt) => $stmt instanceof Class_); } } diff --git a/packages/generation/src/Php/Imports.php b/packages/generation/src/Php/Imports.php index 9538d6d022..6ae2883f19 100644 --- a/packages/generation/src/Php/Imports.php +++ b/packages/generation/src/Php/Imports.php @@ -7,6 +7,9 @@ use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Declare_; +use PhpParser\Node\Stmt\GroupUse; +use PhpParser\Node\Stmt\Use_; use PhpParser\Node\UseItem; /** @@ -33,13 +36,13 @@ public static function from(array $stmts): self $imports = new self(); foreach ($stmts as $stmt) { - if ($stmt instanceof Stmt\Use_) { + if ($stmt instanceof Use_) { foreach ($stmt->uses as $item) { $imports->register($stmt->type, $item->name->toString(), $item->getAlias()->toString()); } - } elseif ($stmt instanceof Stmt\GroupUse) { + } elseif ($stmt instanceof GroupUse) { foreach ($stmt->uses as $item) { - $type = $stmt->type !== Stmt\Use_::TYPE_UNKNOWN ? $stmt->type : $item->type; + $type = $stmt->type !== Use_::TYPE_UNKNOWN ? $stmt->type : $item->type; $imports->register($type, Name::concat($stmt->prefix, $item->name)->toString(), $item->getAlias()->toString()); } } @@ -63,7 +66,7 @@ public static function keyOf(Name $name, int $type): ?string } if ($name->isQualified()) { - return self::key(Stmt\Use_::TYPE_NORMAL, $name->getFirst()); + return self::key(Use_::TYPE_NORMAL, $name->getFirst()); } return self::key($type, $name->getFirst()); @@ -89,7 +92,7 @@ public function resolveClassName(Name $name, ?string $namespace): string } if (! $name->isRelative()) { - $import = $this->resolve(Stmt\Use_::TYPE_NORMAL, $name->getFirst()); + $import = $this->resolve(Use_::TYPE_NORMAL, $name->getFirst()); if ($import !== null) { return $name->isQualified() ? $import . '\\' . $name->slice(1)?->toString() : $import; @@ -129,7 +132,7 @@ public function insertInto(array &$stmts): void continue; } - self::insertImport($stmts, $type, new Stmt\Use_([$item], $type)); + $this->insertImport($stmts, $type, new Use_([$item], $type)); } } @@ -140,7 +143,7 @@ public function insertInto(array &$stmts): void public static function hasUnused(array $stmts, array $used): bool { foreach ($stmts as $stmt) { - if (! $stmt instanceof Stmt\Use_) { + if (! $stmt instanceof Use_) { continue; } @@ -163,7 +166,7 @@ public static function hasUnused(array $stmts, array $used): bool public static function removeUnused(array &$stmts, array $previouslyUsed, array $used, string $comments): void { foreach ($stmts as $index => $stmt) { - if (! $stmt instanceof Stmt\Use_) { + if (! $stmt instanceof Use_) { continue; } @@ -204,16 +207,16 @@ private function updateExistingImport(array $stmts, int $type, string $name, Use { foreach ($stmts as $stmt) { $items = match (true) { - $stmt instanceof Stmt\Use_ && $stmt->type === $type => $stmt->uses, - $stmt instanceof Stmt\GroupUse => array_filter( + $stmt instanceof Use_ && $stmt->type === $type => $stmt->uses, + $stmt instanceof GroupUse => array_filter( $stmt->uses, - static fn (UseItem $item) => ($stmt->type !== Stmt\Use_::TYPE_UNKNOWN ? $stmt->type : $item->type) === $type, + static fn (UseItem $item) => ($stmt->type !== 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; + $itemName = $stmt instanceof GroupUse ? Name::concat($stmt->prefix, $item->name) : $item->name; if (strcasecmp($itemName->toString(), $name) !== 0) { continue; @@ -233,18 +236,18 @@ private function updateExistingImport(array $stmts, int $type, string $name, Use /** * @param Stmt[] $stmts */ - private static function insertImport(array &$stmts, int $type, Stmt\Use_ $import): void + private function insertImport(array &$stmts, int $type, 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)) { + if (! $stmt instanceof Use_ && ! $stmt instanceof GroupUse && ! $stmt instanceof Declare_ && ! Printer::isBlankLine($stmt)) { break; } - if (! $stmt instanceof Stmt\Use_) { + if (! $stmt instanceof Use_) { continue; } @@ -282,7 +285,7 @@ private static function insertImport(array &$stmts, int $type, Stmt\Use_ $import $position = 0; - while (isset($stmts[$position]) && $stmts[$position] instanceof Stmt\Declare_) { + while (isset($stmts[$position]) && $stmts[$position] instanceof Declare_) { $position++; } diff --git a/packages/generation/src/Php/MethodBody.php b/packages/generation/src/Php/MethodBody.php index a3dfb3fb16..274e13650e 100644 --- a/packages/generation/src/Php/MethodBody.php +++ b/packages/generation/src/Php/MethodBody.php @@ -6,8 +6,11 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ShellExec; use PhpParser\Node\Name; use PhpParser\Node\Scalar; +use PhpParser\Node\Scalar\InterpolatedString; +use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PhpParser\NodeFinder; @@ -96,10 +99,12 @@ public function print(string $indentation): string $lines = explode("\n", $code); foreach ($lines as $index => $line) { - if ($line === '' || isset($this->verbatimLines[$index])) { + if ($line === '') { + continue; + } + if (isset($this->verbatimLines[$index])) { continue; } - $lines[$index] = $indentation . substr($line, min($this->indentation, strspn($line, " \t"))); } @@ -119,7 +124,7 @@ 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 instanceof String_ || $node instanceof InterpolatedString || $node instanceof ShellExec) && $node->getStartLine() !== $node->getEndLine() ), ); @@ -129,7 +134,7 @@ private static function verbatimLines(array $stmts, array $lines): array 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); + $isHeredoc = in_array($string->getAttribute('kind'), [String_::KIND_HEREDOC, String_::KIND_NOWDOC], strict: true); if ($isHeredoc && ! self::hasTabIndentation(array_intersect_key($lines, array_flip($stringLines)))) { continue; @@ -150,10 +155,12 @@ private static function indentationOf(array $lines, array $verbatimLines): int $indentation = PHP_INT_MAX; foreach ($lines as $index => $line) { - if (isset($verbatimLines[$index]) || trim($line) === '') { + if (isset($verbatimLines[$index])) { + continue; + } + if (trim($line) === '') { continue; } - $indentation = min($indentation, strspn($line, " \t")); } diff --git a/packages/generation/src/Php/NameSimplifier.php b/packages/generation/src/Php/NameSimplifier.php index 78fdb445e1..1145996b11 100644 --- a/packages/generation/src/Php/NameSimplifier.php +++ b/packages/generation/src/Php/NameSimplifier.php @@ -6,8 +6,17 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; +use PhpParser\Node\Stmt\GroupUse; +use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\Stmt\Use_; /** * Makes sure every name resolves correctly in its namespace, importing fully qualified names where possible. @@ -55,17 +64,17 @@ public function simplify(array $stmts): void } foreach ($stmts as $stmt) { - $this->visit($stmt, null, Stmt\Use_::TYPE_NORMAL, inBody: false, inImplements: false, methodBody: null); + $this->visit($stmt, null, 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), + Use_::TYPE_NORMAL => $this->simplifyClassName($name, $inBody, $inImplements), + Use_::TYPE_FUNCTION => $this->simplifyFunctionName($name, $inBody), default => $this->qualifyBrokenName($name, $type), }; - if ($simplified !== null) { + if ($simplified instanceof Name) { $this->replace($name, $simplified); $methodBody?->replaceName($name, $simplified); } @@ -85,7 +94,7 @@ public function simplify(array $stmts): void */ private function visit(Node $node, ?Node $parent, int $nameType, bool $inBody, bool $inImplements, ?MethodBody $methodBody): void { - if ($parent !== null) { + if ($parent instanceof Node) { $this->parents[spl_object_id($node)] = $parent; } @@ -98,29 +107,29 @@ private function visit(Node $node, ?Node $parent, int $nameType, bool $inBody, b return; } - if ($node instanceof Stmt\Use_ || $node instanceof Stmt\GroupUse) { + if ($node instanceof Use_ || $node instanceof GroupUse) { return; } - if ($node instanceof Stmt\Function_) { + if ($node instanceof 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, + $node instanceof Namespace_ => self::IGNORED, + $node instanceof FuncCall => Use_::TYPE_FUNCTION, + $node instanceof ConstFetch => Use_::TYPE_CONSTANT, + default => Use_::TYPE_NORMAL, }; - $hasBody = $node instanceof Stmt\ClassMethod || $node instanceof Stmt\Function_; - $bodyMethodBody = $node instanceof Stmt\ClassMethod ? $node->getAttribute(MethodBody::ATTRIBUTE) ?? $methodBody : $methodBody; + $hasBody = $node instanceof ClassMethod || $node instanceof Function_; + $bodyMethodBody = $node instanceof 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'; + $childInImplements = $node instanceof Class_ && $subNodeName === 'implements'; foreach (is_array($value) ? $value : [$value] as $child) { if (! $child instanceof Node) { @@ -143,15 +152,15 @@ private function reserve(Name $name, int $type): void $first = $name->getFirst(); - if ($type !== Stmt\Use_::TYPE_NORMAL && $name->isUnqualified()) { - if ($type === Stmt\Use_::TYPE_FUNCTION && $this->imports->resolve($type, $first) === null) { + if ($type !== Use_::TYPE_NORMAL && $name->isUnqualified()) { + if ($type === 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) { + if ($this->imports->resolve(Use_::TYPE_NORMAL, $first) !== null) { return; } @@ -226,16 +235,16 @@ private function simplifyClassName(Name $name, bool $inBody, bool $inImplements) $alias = $this->importClass($fullyQualifiedName); if ($alias === null) { - return $name->isFullyQualified() ? null : new Name\FullyQualified($fullyQualifiedName); + return $name->isFullyQualified() ? null : new FullyQualified($fullyQualifiedName); } - return new Name($alias, [Names::RESOLVED_NAME_ATTRIBUTE => new Name\FullyQualified($fullyQualifiedName)]); + return new Name($alias, [Names::RESOLVED_NAME_ATTRIBUTE => new FullyQualified($fullyQualifiedName)]); } private function simplifyFunctionName(Name $name, bool $inBody): ?Name { if (! $name->isFullyQualified()) { - return $this->qualifyBrokenName($name, Stmt\Use_::TYPE_FUNCTION); + return $this->qualifyBrokenName($name, Use_::TYPE_FUNCTION); } if ($inBody && ! $this->simplifyBodies) { @@ -248,16 +257,16 @@ private function simplifyFunctionName(Name $name, bool $inBody): ?Name return null; } - $alias = $this->imports->aliasOf(Stmt\Use_::TYPE_FUNCTION, $fullyQualifiedName); + $alias = $this->imports->aliasOf(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)])) { + if ($this->imports->resolve(Use_::TYPE_FUNCTION, $alias) !== null || isset($this->reservedFunctions[strtolower($alias)])) { return null; } - $this->imports->add(Stmt\Use_::TYPE_FUNCTION, $fullyQualifiedName); + $this->imports->add(Use_::TYPE_FUNCTION, $fullyQualifiedName); } return new Name($alias); @@ -282,7 +291,7 @@ private function qualifyBrokenName(Name $name, int $type): ?Name return null; } - return new Name\FullyQualified($fullyQualifiedName); + return new FullyQualified($fullyQualifiedName); } /** @@ -290,7 +299,7 @@ private function qualifyBrokenName(Name $name, int $type): ?Name */ private function importClass(string $fullyQualifiedName): ?string { - $alias = $this->imports->aliasOf(Stmt\Use_::TYPE_NORMAL, $fullyQualifiedName); + $alias = $this->imports->aliasOf(Use_::TYPE_NORMAL, $fullyQualifiedName); if ($alias !== null) { return $alias; @@ -298,7 +307,7 @@ private function importClass(string $fullyQualifiedName): ?string $shortName = Names::lastSegment($fullyQualifiedName); - if ($this->imports->resolve(Stmt\Use_::TYPE_NORMAL, $shortName) !== null) { + if ($this->imports->resolve(Use_::TYPE_NORMAL, $shortName) !== null) { return null; } @@ -314,7 +323,7 @@ private function importClass(string $fullyQualifiedName): ?string return null; } - $this->imports->add(Stmt\Use_::TYPE_NORMAL, $fullyQualifiedName); + $this->imports->add(Use_::TYPE_NORMAL, $fullyQualifiedName); return $shortName; } diff --git a/packages/generation/src/Php/PhpClass.php b/packages/generation/src/Php/PhpClass.php index 2084cd91ed..cc33946128 100644 --- a/packages/generation/src/Php/PhpClass.php +++ b/packages/generation/src/Php/PhpClass.php @@ -7,7 +7,11 @@ use PhpParser\Modifiers; use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\TraitUse; use Tempest\Generation\Php\Exceptions\MethodWasMissing; final readonly class PhpClass @@ -16,7 +20,7 @@ * @internal */ public function __construct( - private Stmt\Class_ $node, + private Class_ $node, ) {} public function getName(): string @@ -48,7 +52,7 @@ public function setAbstract(bool $abstract = true): self public function setExtends(?string $class): self { - $this->node->extends = $class !== null ? new Name\FullyQualified(ltrim($class, '\\')) : null; + $this->node->extends = $class !== null ? new FullyQualified(ltrim($class, '\\')) : null; return $this; } @@ -59,18 +63,18 @@ public function addImplement(string $interface): self return $this; } - $this->node->implements[] = new Name\FullyQualified(ltrim($interface, '\\')); + $this->node->implements[] = new FullyQualified(ltrim($interface, '\\')); return $this; } public function addTrait(string $trait): self { - $traitUse = new Stmt\TraitUse([new Name\FullyQualified(ltrim($trait, '\\'))]); + $traitUse = new TraitUse([new FullyQualified(ltrim($trait, '\\'))]); $lastTraitUse = null; foreach ($this->node->stmts as $index => $stmt) { - if (! $stmt instanceof Stmt\TraitUse) { + if (! $stmt instanceof TraitUse) { continue; } @@ -112,7 +116,7 @@ public function setComment(?string $comment): self public function hasMethod(string $name): bool { - return $this->findMethod($name) !== null; + return $this->findMethod($name) instanceof ClassMethod; } public function getMethod(string $name): PhpMethod @@ -127,7 +131,7 @@ public function getMethod(string $name): PhpMethod */ public function addMethod(string $name): PhpMethod { - $method = new Stmt\ClassMethod($name, ['flags' => Modifiers::PUBLIC, 'stmts' => []]); + $method = new ClassMethod($name, ['flags' => Modifiers::PUBLIC, 'stmts' => []]); $index = $this->findMethodIndex($name); if ($index !== null) { @@ -159,13 +163,13 @@ private function setModifier(int $modifier, bool $enabled): self return $this; } - private function findMethod(string $name): ?Stmt\ClassMethod + private function findMethod(string $name): ?ClassMethod { - return array_find($this->node->getMethods(), static fn (Stmt\ClassMethod $method) => $method->name->toLowerString() === strtolower($name)); + return array_find($this->node->getMethods(), static fn (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)); + return array_find_key($this->node->stmts, static fn (Stmt $stmt) => $stmt instanceof ClassMethod && $stmt->name->toLowerString() === strtolower($name)); } } diff --git a/packages/generation/src/Php/PhpFile.php b/packages/generation/src/Php/PhpFile.php index 2baaa960be..b90140b9cd 100644 --- a/packages/generation/src/Php/PhpFile.php +++ b/packages/generation/src/Php/PhpFile.php @@ -8,6 +8,12 @@ use PhpParser\Node\Name; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\Stmt\Declare_; +use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\Stmt\Nop; +use PhpParser\Node\Stmt\Use_; use PhpParser\Token; use Tempest\Generation\Php\Exceptions\ClassWasMissing; use Tempest\Support\Filesystem; @@ -39,7 +45,7 @@ private function __construct( public static function create(?string $namespace = null): self { - return new self($namespace ? [new Stmt\Namespace_(new Name($namespace))] : []); + return new self($namespace ? [new Namespace_(new Name($namespace))] : []); } public static function fromCode(string $code): self @@ -66,16 +72,16 @@ public function setNamespace(string $namespace): self { $existing = $this->findNamespace(); - if ($existing !== null) { + if ($existing instanceof Namespace_) { $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_); + $declares = array_filter($this->stmts, static fn (Stmt $stmt) => $stmt instanceof Declare_); + $stmts = array_filter($this->stmts, static fn (Stmt $stmt) => ! $stmt instanceof Declare_); - $this->stmts = [...$declares, new Stmt\Namespace_(new Name($namespace), array_values($stmts))]; + $this->stmts = [...$declares, new Namespace_(new Name($namespace), array_values($stmts))]; return $this; } @@ -91,16 +97,16 @@ public function setStrictTypes(bool $strictTypes = true): self { $declaration = $this->findStrictTypesDeclaration(); - if ($strictTypes && $declaration !== null) { + if ($strictTypes && $declaration instanceof DeclareItem) { $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))]), + new Declare_([new DeclareItem('strict_types', new Int_(1))]), Printer::blankLine(), ]); - } elseif ($declaration !== null) { + } elseif ($declaration instanceof DeclareItem) { $this->removeDeclaration($declaration); } @@ -114,7 +120,7 @@ public function setComment(?string $comment): self } if ($comment !== null) { - array_unshift($this->stmts, new Stmt\Nop(['comments' => [DocComment::from($comment)]])); + array_unshift($this->stmts, new Nop(['comments' => [DocComment::from($comment)]])); } return $this; @@ -128,7 +134,7 @@ 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->add(Use_::TYPE_NORMAL, ltrim($name, '\\'), $alias); $imports->insertInto($stmts); return $this; @@ -136,7 +142,7 @@ public function addUse(string $name, ?string $alias = null): self public function hasClass(?string $name = null): bool { - return $this->findClass($name) !== null; + return $this->findClass($name) instanceof Class_; } public function getClass(?string $name = null): PhpClass @@ -146,7 +152,7 @@ public function getClass(?string $name = null): PhpClass public function addClass(string $name): PhpClass { - $class = new Stmt\Class_($name); + $class = new Class_($name); $stmts = &$this->body(); array_push($stmts, ...$stmts === [] ? [$class] : [Printer::blankLine(), $class]); @@ -219,7 +225,7 @@ private function resolveNames(): void private function &body(): array { foreach ($this->stmts as $stmt) { - if ($stmt instanceof Stmt\Namespace_) { + if ($stmt instanceof Namespace_) { return $stmt->stmts; } } @@ -227,18 +233,20 @@ private function &body(): array return $this->stmts; } - private function findNamespace(): ?Stmt\Namespace_ + private function findNamespace(): ?Namespace_ { - return array_find($this->stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\Namespace_); + return array_find($this->stmts, static fn (Stmt $stmt) => $stmt instanceof Namespace_); } - private function findClass(?string $name): ?Stmt\Class_ + private function findClass(?string $name): ?Class_ { foreach ($this->body() as $stmt) { - if (! $stmt instanceof Stmt\Class_ || $stmt->name === null) { + if (! $stmt instanceof Class_) { + continue; + } + if ($stmt->name === null) { continue; } - if ($name === null || $stmt->name->toLowerString() === strtolower($name)) { return $stmt; } @@ -255,10 +263,12 @@ private function declaredClasses(): array $classes = []; foreach ($this->body() as $stmt) { - if (! $stmt instanceof Stmt\ClassLike || $stmt->name === null) { + if (! $stmt instanceof ClassLike) { + continue; + } + if ($stmt->name === null) { continue; } - $classes[$stmt->name->toLowerString()] = true; } @@ -268,7 +278,7 @@ private function declaredClasses(): array private function findStrictTypesDeclaration(): ?DeclareItem { foreach ($this->stmts as $stmt) { - if (! $stmt instanceof Stmt\Declare_) { + if (! $stmt instanceof Declare_) { continue; } @@ -285,10 +295,12 @@ private function findStrictTypesDeclaration(): ?DeclareItem private function removeDeclaration(DeclareItem $declaration): void { foreach ($this->stmts as $index => $stmt) { - if (! $stmt instanceof Stmt\Declare_ || ! in_array($declaration, $stmt->declares, strict: true)) { + if (! $stmt instanceof Declare_) { + continue; + } + if (! 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 === []) { @@ -319,7 +331,7 @@ private function comments(): string $comments .= $comment->getText() . "\n"; } - if (! $stmt instanceof Stmt\Class_) { + if (! $stmt instanceof Class_) { continue; } @@ -335,6 +347,6 @@ private function hasComment(): bool { $first = $this->stmts[0] ?? null; - return $first instanceof Stmt\Nop && ! Printer::isBlankLine($first) && $first->getComments() !== []; + return $first instanceof Nop && ! Printer::isBlankLine($first) && $first->getComments() !== []; } } diff --git a/packages/generation/src/Php/PhpMethod.php b/packages/generation/src/Php/PhpMethod.php index 7f34a56e57..5715341471 100644 --- a/packages/generation/src/Php/PhpMethod.php +++ b/packages/generation/src/Php/PhpMethod.php @@ -7,6 +7,7 @@ use PhpParser\Node\Expr\Variable; use PhpParser\Node\Param; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\ClassMethod; final readonly class PhpMethod { @@ -14,7 +15,7 @@ * @internal */ public function __construct( - private Stmt\ClassMethod $node, + private ClassMethod $node, ) {} public function getName(): string diff --git a/packages/generation/src/Php/Printer.php b/packages/generation/src/Php/Printer.php index 56543998db..5558d8e5c7 100644 --- a/packages/generation/src/Php/Printer.php +++ b/packages/generation/src/Php/Printer.php @@ -5,8 +5,21 @@ namespace Tempest\Generation\Php; use Override; +use PhpParser\Internal\TokenStream; use PhpParser\Node; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; +use PhpParser\Node\Param; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Declare_; +use PhpParser\Node\Stmt\EnumCase; +use PhpParser\Node\Stmt\InlineHTML; +use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\Stmt\Nop; +use PhpParser\Node\Stmt\TraitUse; +use PhpParser\Node\Stmt\Use_; use PhpParser\PhpVersion; use PhpParser\PrettyPrinter\Standard; use PhpParser\Token; @@ -35,14 +48,14 @@ public function __construct() /** * A statement that separates the surrounding statements by a blank line when they are inserted into existing code. */ - public static function blankLine(): Stmt\Nop + public static function blankLine(): Nop { - return new Stmt\Nop([self::BLANK_LINE_ATTRIBUTE => true]); + return new 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; + return $node instanceof Nop && $node->getAttribute(self::BLANK_LINE_ATTRIBUTE) === true; } /** @@ -72,12 +85,12 @@ public function printChangedFile(array $stmts, array $originalStmts, array $toke #[Override] protected function p(Node $node, int $precedence = self::MAX_PRECEDENCE, int $lhsPrecedence = self::MAX_PRECEDENCE, bool $parentFormatPreserved = false): string { - if ($this->origTokens === null) { + if (! $this->origTokens instanceof TokenStream) { return $this->{'p' . $node->getType()}($node, $precedence, $lhsPrecedence); } $original = $node->getAttribute('origNode'); - $canBeCopied = $parentFormatPreserved || ! $node instanceof Stmt\InlineHTML; + $canBeCopied = $parentFormatPreserved || ! $node instanceof InlineHTML; if ($original instanceof Node && $canBeCopied && Changes::isUnchanged($node)) { $start = $original->getStartTokenPos(); @@ -97,19 +110,19 @@ protected function pArray(array $nodes, array $origNodes, int &$pos, int $indent } $code = $this->origTokens->getTokenCode($pos, $origNodes[0]->getStartTokenPos(), $indentAdjustment); - $pos = $this->origTokens->skipRightWhitespace($origNodes[array_key_last($origNodes)]->getEndTokenPos() + 1); + $pos = $this->origTokens->skipRightWhitespace(array_last($origNodes)->getEndTokenPos() + 1); return $code; } #[Override] - protected function pStmt_Nop(Stmt\Nop $node): string + protected function pStmt_Nop(Nop $node): string { return self::isBlankLine($node) ? self::BLANK_LINE_MARKER : ''; } #[Override] - protected function pStmt_Declare(Stmt\Declare_ $node): string + protected function pStmt_Declare(Declare_ $node): string { $stmts = $node->stmts; @@ -117,11 +130,11 @@ protected function pStmt_Declare(Stmt\Declare_ $node): string } #[Override] - protected function pStmt_Namespace(Stmt\Namespace_ $node): string + protected function pStmt_Namespace(Namespace_ $node): string { $name = $node->name; - if (! $this->canUseSemicolonNamespaces || $name === null) { + if (! $this->canUseSemicolonNamespaces || ! $name instanceof Name) { return parent::pStmt_Namespace($node); } @@ -131,15 +144,15 @@ protected function pStmt_Namespace(Stmt\Namespace_ $node): string } #[Override] - protected function pClassCommon(Stmt\Class_ $node, string $afterClassToken): string + protected function pClassCommon(Class_ $node, string $afterClassToken): string { $extends = $node->extends; $declaration = - $this->pAttrGroups($node->attrGroups, $node->name === null) + $this->pAttrGroups($node->attrGroups, ! $node->name instanceof Identifier) . $this->pModifiers($node->flags) . 'class' . $afterClassToken - . ($extends !== null ? ' extends ' . $this->p($extends) : '') + . ($extends instanceof Name ? ' extends ' . $this->p($extends) : '') . ($node->implements !== [] ? ' implements ' . $this->pCommaSeparated($node->implements) : ''); $this->indent(); @@ -154,7 +167,7 @@ protected function pClassCommon(Stmt\Class_ $node, string $afterClassToken): str } #[Override] - protected function pStmt_ClassMethod(Stmt\ClassMethod $node): string + protected function pStmt_ClassMethod(ClassMethod $node): string { $multiline = $this->shouldBreakParameters($node->params); $returnType = $node->returnType; @@ -168,7 +181,7 @@ protected function pStmt_ClassMethod(Stmt\ClassMethod $node): string . '(' . $this->pParams($node->params) . ')' - . ($returnType !== null ? ': ' . $this->p($returnType) : ''); + . ($returnType instanceof Node ? ': ' . $this->p($returnType) : ''); if ($node->stmts === null) { return $signature . ';'; @@ -199,7 +212,7 @@ protected function pParams(array $params): string } #[Override] - protected function pParam(Node\Param $node): string + protected function pParam(Param $node): string { if (! $this->multilineParameters || $node->attrGroups === []) { return parent::pParam($node); @@ -235,7 +248,7 @@ private function pBlocks(array $nodes): string $comments = $node->getComments(); - if ($node instanceof Stmt\Nop) { + if ($node instanceof Nop) { $code .= $this->pComments($comments); } else { $code .= ($comments !== [] ? $this->pComments($comments) . $this->nl : '') . $this->p($node); @@ -249,14 +262,14 @@ private function pBlocks(array $nodes): string private function belongTogether(Node $previous, Node $node): bool { - if ($previous instanceof Stmt\Use_ && $node instanceof Stmt\Use_) { + if ($previous instanceof Use_ && $node instanceof Use_) { return $previous->type === $node->type; } - return $previous::class === $node::class && ($node instanceof Stmt\TraitUse || $node instanceof Stmt\EnumCase); + return $previous::class === $node::class && ($node instanceof TraitUse || $node instanceof EnumCase); } - private function pMethodBody(Stmt\ClassMethod $node): string + private function pMethodBody(ClassMethod $node): string { $methodBody = $node->getAttribute(MethodBody::ATTRIBUTE); @@ -274,7 +287,7 @@ private function pMethodBody(Stmt\ClassMethod $node): string */ private function shouldBreakParameters(array $params): bool { - return array_any($params, static fn (Node\Param $param) => $param->flags !== 0 || $param->attrGroups !== [] || $param->getComments() !== []); + return array_any($params, static fn (Param $param) => $param->flags !== 0 || $param->attrGroups !== [] || $param->getComments() !== []); } private function indentation(int $additionalLevels): string diff --git a/packages/generation/src/Php/SourceParser.php b/packages/generation/src/Php/SourceParser.php index 6eca336fd6..3d014f94e1 100644 --- a/packages/generation/src/Php/SourceParser.php +++ b/packages/generation/src/Php/SourceParser.php @@ -7,6 +7,7 @@ use PhpParser\Error; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Expression; use PhpParser\Parser; use PhpParser\ParserFactory; use PhpParser\PhpVersion; @@ -40,7 +41,7 @@ public static function parseExpression(string $code): Expr { [$stmts] = self::parse(" 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')), + $value instanceof UnitEnum => new ClassConstFetch(new FullyQualified($value::class), $value->name), + $value === null => new ConstFetch(new Name('null')), + is_bool($value) => new 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_float($value) => new Float_($value), + is_string($value) => new String_($value), is_array($value) => self::exportArray($value), default => throw new ValueWasNotExportable($value), }; @@ -51,13 +60,13 @@ public static function exportArguments(array $arguments): array 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 new Minus(new UnaryMinus(new Int_(PHP_INT_MAX)), new Int_(1)); } - return $value < 0 ? new Expr\UnaryMinus(new Scalar\Int_(-$value)) : new Scalar\Int_($value); + return $value < 0 ? new UnaryMinus(new Int_(-$value)) : new Int_($value); } - private static function exportArray(array $values): Expr\Array_ + private static function exportArray(array $values): Array_ { $items = []; $nextIndex = 0; @@ -70,6 +79,6 @@ private static function exportArray(array $values): Expr\Array_ } } - return new Expr\Array_($items, ['kind' => Expr\Array_::KIND_SHORT]); + return new Array_($items, ['kind' => Array_::KIND_SHORT]); } } diff --git a/packages/upgrade/config/sets/level/up-to-tempest-322.php b/packages/upgrade/config/sets/level/up-to-tempest-322.php new file mode 100644 index 0000000000..6fbde538a6 --- /dev/null +++ b/packages/upgrade/config/sets/level/up-to-tempest-322.php @@ -0,0 +1,19 @@ +sets([ + TempestSetList::TEMPEST_20, + TempestSetList::TEMPEST_28, + TempestSetList::TEMPEST_30, + TempestSetList::TEMPEST_34, + TempestSetList::TEMPEST_310, + TempestSetList::TEMPEST_314, + TempestSetList::TEMPEST_321, + TempestSetList::TEMPEST_322, + ]); +}; diff --git a/packages/upgrade/config/sets/tempest322.php b/packages/upgrade/config/sets/tempest322.php new file mode 100644 index 0000000000..428b686b66 --- /dev/null +++ b/packages/upgrade/config/sets/tempest322.php @@ -0,0 +1,13 @@ +rule(UpdateContainerImplementationsRector::class); +}; diff --git a/packages/upgrade/src/Set/TempestLevelSetList.php b/packages/upgrade/src/Set/TempestLevelSetList.php index a9836c64f8..29019ee9c5 100644 --- a/packages/upgrade/src/Set/TempestLevelSetList.php +++ b/packages/upgrade/src/Set/TempestLevelSetList.php @@ -19,4 +19,6 @@ final class TempestLevelSetList public const string UP_TO_TEMPEST_314 = __DIR__ . '/../../config/sets/level/up-to-tempest-314.php'; public const string UP_TO_TEMPEST_321 = __DIR__ . '/../../config/sets/level/up-to-tempest-321.php'; + + public const string UP_TO_TEMPEST_322 = __DIR__ . '/../../config/sets/level/up-to-tempest-322.php'; } diff --git a/packages/upgrade/src/Set/TempestSetList.php b/packages/upgrade/src/Set/TempestSetList.php index cf475b8265..462e424ec6 100644 --- a/packages/upgrade/src/Set/TempestSetList.php +++ b/packages/upgrade/src/Set/TempestSetList.php @@ -19,4 +19,6 @@ final class TempestSetList public const string TEMPEST_314 = __DIR__ . '/../../config/sets/tempest314.php'; public const string TEMPEST_321 = __DIR__ . '/../../config/sets/tempest321.php'; + + public const string TEMPEST_322 = __DIR__ . '/../../config/sets/tempest322.php'; } diff --git a/packages/upgrade/src/Tempest322/UpdateContainerImplementationsRector.php b/packages/upgrade/src/Tempest322/UpdateContainerImplementationsRector.php new file mode 100644 index 0000000000..6342f2f7b9 --- /dev/null +++ b/packages/upgrade/src/Tempest322/UpdateContainerImplementationsRector.php @@ -0,0 +1,62 @@ +implementsContainer($node)) { + return null; + } + + $singleton = $node->getMethod('singleton'); + + if (! $singleton instanceof ClassMethod || count($singleton->params) !== 3) { + return null; + } + + $singleton->params[] = new Param( + var: new Variable('lifetime'), + default: new ConstFetch(new Name('null')), + type: new NullableType(new FullyQualified(Lifetime::class)), + ); + + return $node; + } + + private function implementsContainer(Class_ $class): bool + { + foreach ($class->implements as $interface) { + $resolvedName = $interface->getAttribute('resolvedName'); + + if ($resolvedName instanceof Name && $resolvedName->toString() === Container::class) { + return true; + } + + if ($interface->toString() === Container::class) { + return true; + } + } + + return false; + } +} diff --git a/packages/upgrade/tests/Tempest322/Fixtures/ContainerImplementation.input.php b/packages/upgrade/tests/Tempest322/Fixtures/ContainerImplementation.input.php new file mode 100644 index 0000000000..b99288d336 --- /dev/null +++ b/packages/upgrade/tests/Tempest322/Fixtures/ContainerImplementation.input.php @@ -0,0 +1,14 @@ + new RectorTester(__DIR__ . '/tempest322_rector.php'); + } + + #[Test] + public function container_implementations_receive_the_optional_lifetime(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/ContainerImplementation.input.php') + ->assertContains('use Tempest\\Container\\Lifetime;') + ->assertContains('public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self'); + } + + #[Test] + public function unrelated_or_already_updated_methods_are_unchanged(): void + { + $this->assertSame('', $this->rector->runFixture(__DIR__ . '/Fixtures/Unchanged.input.php')->actual); + } +} diff --git a/packages/upgrade/tests/Tempest322/tempest322_rector.php b/packages/upgrade/tests/Tempest322/tempest322_rector.php new file mode 100644 index 0000000000..cc9dac9a66 --- /dev/null +++ b/packages/upgrade/tests/Tempest322/tempest322_rector.php @@ -0,0 +1,11 @@ +withSets([TempestSetList::TEMPEST_322]) + ->withCache(cacheClass: MemoryCacheStorage::class); diff --git a/tests/Integration/Container/Commands/ContainerShowCommandTest.php b/tests/Integration/Container/Commands/ContainerShowCommandTest.php index 6de904d3e4..2e89df6628 100644 --- a/tests/Integration/Container/Commands/ContainerShowCommandTest.php +++ b/tests/Integration/Container/Commands/ContainerShowCommandTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Tempest\Container\Commands\ContainerShowCommand; use Tempest\Container\Container; +use Tempest\Container\Lifetime; use Tempest\Reflection\ClassReflector; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use UnitEnum; @@ -45,9 +46,9 @@ public function unregister(string $className, bool $tagged = false): self return $this; } - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self { - $this->container->singleton($className, $definition, $tag); + $this->container->singleton($className, $definition, $tag, $lifetime); return $this; } diff --git a/tests/Integration/Container/ContainerLifetimeDiscoveryTest.php b/tests/Integration/Container/ContainerLifetimeDiscoveryTest.php new file mode 100644 index 0000000000..a2489e17c7 --- /dev/null +++ b/tests/Integration/Container/ContainerLifetimeDiscoveryTest.php @@ -0,0 +1,70 @@ +setItems(new DiscoveryItems()); + $discovery->discover(new DiscoveryLocation(__NAMESPACE__, __DIR__), new ClassReflector($initializer)); + $discovery->apply(); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class)); + } + } + } + + #[Test] + public function discovered_interface_bindings_follow_the_class_lifetime(): void + { + $container = new GenericContainer(); + $discovery = new AutowireDiscovery($container); + $discovery->setItems(new DiscoveryItems()); + $discovery->discover(new DiscoveryLocation(__NAMESPACE__, __DIR__), new ClassReflector(RequestLifetimeSingleton::class)); + $discovery->apply(); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + + $container->reset(); + + $next = $container->get(RequestLifetimeInterface::class); + $this->assertNotSame($instance, $next); + $this->assertSame($next, $container->get(RequestLifetimeSingleton::class)); + } + } +} diff --git a/tests/Integration/Container/Fixtures/RequestLifetimeClassInitializer.php b/tests/Integration/Container/Fixtures/RequestLifetimeClassInitializer.php new file mode 100644 index 0000000000..2772ae2a9c --- /dev/null +++ b/tests/Integration/Container/Fixtures/RequestLifetimeClassInitializer.php @@ -0,0 +1,19 @@ +getName() === RequestLifetimeInterface::class; + } + + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): object + { + return new RequestLifetimeSingleton(); + } +} diff --git a/tests/Integration/Container/Fixtures/RequestLifetimeInitializer.php b/tests/Integration/Container/Fixtures/RequestLifetimeInitializer.php new file mode 100644 index 0000000000..645407f0a1 --- /dev/null +++ b/tests/Integration/Container/Fixtures/RequestLifetimeInitializer.php @@ -0,0 +1,19 @@ +