diff --git a/composer.json b/composer.json index 28969d5..76aedb5 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ ], "require": { "php": "~8.1", + "crell/attributeutils": "^1.1", "crell/ordered-collection": "v2.x-dev", "fig/event-dispatcher-util": "^1.3", "psr/container": "^1.0 || ^2.0", diff --git a/src/Listener.php b/src/Listener.php index b5b4384..36ae5c3 100644 --- a/src/Listener.php +++ b/src/Listener.php @@ -5,13 +5,34 @@ namespace Crell\Tukio; use Attribute; +use Crell\AttributeUtils\Finalizable; +use Crell\AttributeUtils\FromReflectionMethod; +use Crell\AttributeUtils\HasSubAttributes; +use Crell\AttributeUtils\ParseMethods; +use Crell\AttributeUtils\ParseStaticMethods; +use Crell\AttributeUtils\ReadsClass; /** * The main attribute to customize a listener. */ #[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] -class Listener implements ListenerAttribute +class Listener implements ListenerAttribute, HasSubAttributes, ParseMethods, ReadsClass, Finalizable, FromReflectionMethod, ParseStaticMethods { + /** + * @var Listener[] + * + * This is only used by the class-level attribute. When used on a method level it is ignored. + */ + public readonly array $methods; + + /** + * @var Listener[] + * + * This is only used by the class-level attribute. When used on a method level it is ignored. + */ + public readonly array $staticMethods; + + /** @var string[] */ public array $before = []; @@ -19,6 +40,13 @@ class Listener implements ListenerAttribute public array $after = []; public ?int $priority = null; + public readonly bool $hasDefinition; + + /** + * This is only meaningful on the method attribute. + */ + public readonly int $paramCount; + /** * @param ?string $id * The identifier by which this listener should be known. If not specified one will be generated. @@ -29,13 +57,93 @@ class Listener implements ListenerAttribute public function __construct( public ?string $id = null, public ?string $type = null, - ) {} + ) { + if ($id || $this->type) { + $this->hasDefinition = true; + } + } + + public function fromReflection(\ReflectionMethod $subject): void + { + $this->paramCount = $subject->getNumberOfRequiredParameters(); + if ($this->paramCount === 1) { + $params = $subject->getParameters(); + // getName() isn't part of the interface, but is present. PHP bug. + // @phpstan-ignore-next-line + $this->type ??= $params[0]->getType()?->getName(); + } + } + + /** + * This will only get called when this attribute is on a class. + * + * @param Listener[] $methods + */ + public function setMethods(array $methods): void + { + $this->methods = $methods; + } + + public function includeMethodsByDefault(): bool + { + return true; + } + + public function methodAttribute(): string + { + return __CLASS__; + } + + /** + * @param array $methods + */ + public function setStaticMethods(array $methods): void + { + $this->staticMethods = $methods; + } + + public function includeStaticMethodsByDefault(): bool + { + return true; + } + + public function staticMethodAttribute(): string + { + return __CLASS__; + } + + + /** + * This will only get called when this attribute is used on a method. + * + * @param Listener $class + */ + public function fromClassAttribute(object $class): void + { + $this->id ??= $class->id; + $this->type ??= $class->type; + $this->priority ??= $class->priority; + $this->before ??= $class->before; + $this->after ??= $class->after; + } + + public function subAttributes(): array + { + return [ + ListenerBefore::class => 'fromBefore', + ListenerAfter::class => 'fromAfter', + ListenerPriority::class => 'fromPriority', + ]; + } /** * @param array $attribs */ - public function absorbBefore(array $attribs): void + public function fromBefore(array $attribs): void { + if ($attribs) { + $this->hasDefinition ??= true; + } foreach ($attribs as $attrib) { $this->id ??= $attrib->id; $this->type ??= $attrib->type; @@ -46,8 +154,11 @@ public function absorbBefore(array $attribs): void /** * @param array $attribs */ - public function absorbAfter(array $attribs): void + public function fromAfter(array $attribs): void { + if ($attribs) { + $this->hasDefinition ??= true; + } foreach ($attribs as $attrib) { $this->id ??= $attrib->id; $this->type ??= $attrib->type; @@ -55,10 +166,20 @@ public function absorbAfter(array $attribs): void } } - public function absorbPriority(ListenerPriority $attrib): void + public function fromPriority(?ListenerPriority $attrib): void + { + if ($attrib) { + $this->hasDefinition ??= true; + } + $this->id ??= $attrib?->id; + $this->type ??= $attrib?->type; + $this->priority = $attrib?->priority; + } + + public function finalize(): void { - $this->id ??= $attrib->id; - $this->type ??= $attrib->type; - $this->priority = $attrib->priority; + $this->methods ??= []; + $this->hasDefinition ??= false; } + } diff --git a/src/ListenerAfter.php b/src/ListenerAfter.php index ef5e1e5..84e6f1b 100644 --- a/src/ListenerAfter.php +++ b/src/ListenerAfter.php @@ -5,9 +5,10 @@ namespace Crell\Tukio; use Attribute; +use Crell\AttributeUtils\Multivalue; -#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class ListenerAfter implements ListenerAttribute +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +class ListenerAfter implements ListenerAttribute, Multivalue { /** @var string[] */ public array $after = []; diff --git a/src/ListenerBefore.php b/src/ListenerBefore.php index db0db92..3e29fb0 100644 --- a/src/ListenerBefore.php +++ b/src/ListenerBefore.php @@ -5,9 +5,10 @@ namespace Crell\Tukio; use Attribute; +use Crell\AttributeUtils\Multivalue; -#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class ListenerBefore implements ListenerAttribute +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +class ListenerBefore implements ListenerAttribute, Multivalue { /** @var string[] */ public array $before = []; diff --git a/src/ListenerPriority.php b/src/ListenerPriority.php index 6dd8612..17ba90e 100644 --- a/src/ListenerPriority.php +++ b/src/ListenerPriority.php @@ -6,7 +6,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class ListenerPriority implements ListenerAttribute { public function __construct( diff --git a/src/ListenerProxy.php b/src/ListenerProxy.php index 8541aea..24e9091 100644 --- a/src/ListenerProxy.php +++ b/src/ListenerProxy.php @@ -123,7 +123,7 @@ protected function getServiceMethodType(string $methodName): string { try { // We don't have a real object here, so we cannot use first-class-closures. - // PHPStan complains that an aray is not a callable, even though it is, because PHP. + // PHPStan complains that an array is not a callable, even though it is, because PHP. // @phpstan-ignore-next-line $type = $this->getParameterType([$this->serviceClass, $methodName]); } catch (\InvalidArgumentException $exception) { diff --git a/src/OrderedListenerProvider.php b/src/OrderedListenerProvider.php index 8d80a81..722f42c 100644 --- a/src/OrderedListenerProvider.php +++ b/src/OrderedListenerProvider.php @@ -52,27 +52,34 @@ public function listenerService( $type = $this->getParameterType([$service, $method]); } + $orderSpecified = !is_null($priority) || !empty($before) || !empty($after); + // In the special case that the service is the class name, we can // leverage attributes. - if (class_exists($service)) { + if (!$orderSpecified && class_exists($service)) { $listener = [$service, $method]; /** @var Listener $def */ - $def = $this->getAttributeDefinition($listener); + $def = $this->classAnalyzer->analyze($service, Listener::class); + $def = $def->methods[$method]; $id ??= $def?->id ?? $this->getListenerId($listener); - // If any ordering is specified explicitly, that completely overrules any - // attributes. - if (!is_null($priority) || $before || $after) { - $def->priority = $priority; - $def->before = $before; - $def->after = $after; - } - return $this->listener($this->makeListenerForService($service, $method), priority: $def->priority, before: $def->before, after: $def->after, id: $id, type: $type); + return $this->listeners->add( + item: $this->getListenerEntry($this->makeListenerForService($service, $method), $type), + id: $id, + priority: $def->priority, + before: $def->before, + after: $def->after + ); } - - $id ??= $service . '-' . $method; - return $this->listener($this->makeListenerForService($service, $method), priority: $priority, before: $before, after: $after, id: $id, type: $type); + $id ??= $service . '::' . $method; + return $this->listeners->add( + item: $this->getListenerEntry($this->makeListenerForService($service, $method), $type), + id: $id, + priority: $priority, + before: $before, + after: $after, + ); } /** diff --git a/src/OrderedProviderInterface.php b/src/OrderedProviderInterface.php index 8d71734..d92331f 100644 --- a/src/OrderedProviderInterface.php +++ b/src/OrderedProviderInterface.php @@ -211,17 +211,17 @@ public function addListenerServiceAfter(string $after, string $service, string $ /** * Registers all listener methods on a service as listeners. * - * A method on the specified class is a listener if: - * - It is public. + * A public method on the specified class is a listener if either of these is true: * - It's name is in the form on*. onUpdate(), onUserLogin(), onHammerTime() will all be registered. - * - It has a Listener/ListenerBefore/ListenerAfter attribute. + * - It has a Listener/ListenerBefore/ListenerAfter/ListenerPriority attribute. * - * The event type the listener is for will be derived from the type declaration in the method signature. + * The event type the listener is for will be derived from the type declaration in the method signature, + * unless overriden by an attribute.. * * @param class-string $class * The class name to be registered as a subscriber. - * @param string $service - * The name of a service in the container. + * @param null|string $service + * The name of a service in the container. If not specified, it's assumed to be the same as the class. */ - public function addSubscriber(string $class, string $service): void; + public function addSubscriber(string $class, ?string $service = null): void; } diff --git a/src/ProviderBuilder.php b/src/ProviderBuilder.php index d943cda..2e03110 100644 --- a/src/ProviderBuilder.php +++ b/src/ProviderBuilder.php @@ -53,22 +53,17 @@ public function listenerService( $type = $this->getParameterType([$service, $method]); } + $orderSpecified = !is_null($priority) || !empty($before) || !empty($after); + // In the special case that the service is the class name, we can // leverage attributes. - if (class_exists($service)) { + if (!$orderSpecified && class_exists($service)) { $listener = [$service, $method]; /** @var Listener $def */ - $def = $this->getAttributeDefinition($listener); + $def = $this->classAnalyzer->analyze($service, Listener::class); + $def = $def->methods[$method]; $id ??= $def?->id ?? $this->getListenerId($listener); - // If any ordering is specified explicitly, that completely overrules any - // attributes. - if (!is_null($priority) || $before || $after) { - $def->priority = $priority; - $def->before = $before; - $def->after = $after; - } - $entry = new ListenerServiceEntry($service, $method, $type); return $this->listeners->add($entry, $id, priority: $def->priority, before: $def->before, after: $def->after); } diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php index ce27568..561892b 100644 --- a/src/ProviderCollector.php +++ b/src/ProviderCollector.php @@ -4,6 +4,12 @@ namespace Crell\Tukio; +use Crell\AttributeUtils\Analyzer; +use Crell\AttributeUtils\ClassAnalyzer; +use Crell\AttributeUtils\FuncAnalyzer; +use Crell\AttributeUtils\FunctionAnalyzer; +use Crell\AttributeUtils\MemoryCacheAnalyzer; +use Crell\AttributeUtils\MemoryCacheFunctionAnalyzer; use Crell\OrderedCollection\MultiOrderedCollection; use Crell\Tukio\Entry\ListenerEntry; use Fig\EventDispatcher\ParameterDeriverTrait; @@ -17,8 +23,10 @@ abstract class ProviderCollector implements OrderedProviderInterface */ protected MultiOrderedCollection $listeners; - public function __construct() - { + public function __construct( + protected readonly FunctionAnalyzer $funcAnalyzer = new MemoryCacheFunctionAnalyzer(new FuncAnalyzer()), + protected readonly ClassAnalyzer $classAnalyzer = new MemoryCacheAnalyzer(new Analyzer()), + ) { $this->listeners = new MultiOrderedCollection(); } @@ -30,17 +38,21 @@ public function listener( ?string $id = null, ?string $type = null ): string { - /** @var Listener $def */ - $def = $this->getAttributeDefinition($listener); - $id ??= $def?->id ?? $this->getListenerId($listener); - $type ??= $def?->type ?? $this->getType($listener); - - // If any ordering is specified explicitly, that completely overrules any - // attributes. - if (!is_null($priority) || $before || $after) { - $def->priority = $priority; - $def->before = $before; - $def->after = $after; + $orderSpecified = !is_null($priority) || !empty($before) || !empty($after); + + if (!$orderSpecified || !$type || !$id) { + /** @var Listener $def */ + $def = $this->getAttributeDefinition($listener); + $id ??= $def?->id ?? $this->getListenerId($listener); + $type ??= $def?->type ?? $this->getType($listener); + + // If any ordering is specified explicitly, that completely overrules any + // attributes. + if (!$orderSpecified) { + $priority = $def->priority; + $before = $def->before; + $after = $def->after; + } } $entry = $this->getListenerEntry($listener, $type); @@ -48,9 +60,9 @@ public function listener( return $this->listeners->add( item: $entry, id: $id, - priority: $def->priority, - before: $def->before, - after: $def->after + priority: $priority, + before: $before, + after: $after ); } @@ -84,60 +96,46 @@ public function addListenerServiceAfter(string $after, string $service, string $ return $this->listenerService($service, $method, $type, after: [$after], id: $id); } - public function addSubscriber(string $class, string $service): void + public function addSubscriber(string $class, ?string $service = null): void { - $proxy = $this->addSubscribersByProxy($class, $service); + $service ??= $class; - try { - $methods = (new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC); + // First allow manual registration through the proxy object. + // This is deprecated. Please don't use it. + $proxy = $this->addSubscribersByProxy($class, $service); - $methods = array_filter($methods, static fn(\ReflectionMethod $r) - => !in_array($r->getName(), $proxy->getRegisteredMethods(), true)); + $proxyRegisteredMethods = $proxy->getRegisteredMethods(); - /** @var \ReflectionMethod $rMethod */ - foreach ($methods as $rMethod) { - $this->addSubscriberMethod($rMethod, $class, $service); + try { + // Get all methods on the class, via AttributeUtils to handle reflection and caching. + $methods = $this->classAnalyzer->analyze($class, Listener::class)->methods; + + /** + * @var string $methodName + * @var Listener $def + */ + foreach ($methods as $methodName => $def) { + if (in_array($methodName, $proxyRegisteredMethods, true)) { + // Exclude anything already registered by proxy. + continue; + } + // If there was an attribute-based definition, that takes priority. + if ($def->hasDefinition) { + $this->listenerService($service, $methodName, $def->type, $def->priority, $def->before,$def->after, $def->id); + } elseif (str_starts_with($methodName, 'on') && $def->paramCount === 1) { + // Try to register it iff the method starts with "on" and has only one required parameter. + // (More than one required parameter is guaranteed to fail when invoked.) + if (!$def->type) { + throw InvalidTypeException::fromClassCallable($class, $methodName); + } + $this->listenerService($service, $methodName, type: $def->type, id: $service . '-' . $methodName); + } } } catch (\ReflectionException $e) { throw new \RuntimeException('Type error registering subscriber.', 0, $e); } } - protected function addSubscriberMethod(\ReflectionMethod $rMethod, string $class, string $service): void - { - $methodName = $rMethod->getName(); - $params = $rMethod->getParameters(); - - if (count($params) < 1) { - // Skip this method, as it doesn't take arguments. - return; - } - - $def = $this->getAttributeForRef($rMethod); - - if ($def->id || $def->before || $def->after || $def->priority || str_starts_with($methodName, 'on')) { - $paramType = $params[0]->getType(); - - $id = $def->id ?? $service . '-' . $methodName; - // getName() is not a documented part of the Reflection API, but it's always there. - // @phpstan-ignore-next-line - $type = $def->type ?? $paramType?->getName() ?? throw InvalidTypeException::fromClassCallable($class, $methodName); - - $this->listenerService($service, $methodName, $type, $def->priority, $def->before,$def->after, $id); - } - } - - /** - * @return array - */ - protected function findAttributesOnMethod(\ReflectionMethod $rMethod): array - { - $attributes = array_map(static fn (\ReflectionAttribute $attrib): object - => $attrib->newInstance(), $rMethod->getAttributes(Listener::class, \ReflectionAttribute::IS_INSTANCEOF)); - - return $attributes; - } - /** * @param class-string $class */ @@ -154,76 +152,33 @@ protected function addSubscribersByProxy(string $class, string $service): Listen } /** - * @param callable|array{0: string, 1: string} $listener + * @param callable|array{0: class-string, 1: string}|array{0: object, 1: string} $listener */ protected function getAttributeDefinition(callable|array $listener): Listener { - $ref = null; - - if ($this->isFunctionCallable($listener)) { - /** @var string $listener */ - $ref = new \ReflectionFunction($listener); - // @phpstan-ignore-next-line - } elseif ($this->isClassCallable($listener)) { - // PHPStan says you cannot use array destructuring on a callable, but you can - // if you know that it's an array (which in context we do). - // @phpstan-ignore-next-line - [$class, $method] = $listener; - $ref = (new \ReflectionClass($class))->getMethod($method); - // @phpstan-ignore-next-line - } elseif ($this->isObjectCallable($listener)) { - // PHPStan says you cannot use array destructuring on a callable, but you can - // if you know that it's an array (which in context we do). - // @phpstan-ignore-next-line - [$class, $method] = $listener; - $ref = (new \ReflectionObject($class))->getMethod($method); - } - - if (!$ref) { - return new Listener(); + if ($this->isFunctionCallable($listener) || $this->isClosureCallable($listener)) { + /** @var \Closure|string $listener */ + return $this->funcAnalyzer->analyze($listener, Listener::class); } - return $this->getAttributeForRef($ref); - } - - protected function getAttributeForRef(\Reflector $ref): Listener - { - // All this logic is very similar to AttributeUtils Sub-Attributes. - // Maybe AU can be improved to make sub-attributes accessible outside - // the analyzer? - - /** @var Listener $def */ - $def = $this->getAttributes(Listener::class, $ref)[0] ?? new Listener(); + if ($this->isObjectCallable($listener)) { + /** @var array{0: object, 1: string} $listener */ + [$object, $method] = $listener; - /** @var ListenerBefore[] $beforeAttribs */ - $beforeAttribs = $this->getAttributes(ListenerBefore::class, $ref); - $def->absorbBefore($beforeAttribs); + $def = $this->classAnalyzer->analyze($object::class, Listener::class); + return $def->methods[$method]; + } - /** @var ListenerAfter[] $afterAttribs */ - $afterAttribs = $this->getAttributes(ListenerAfter::class, $ref); - $def->absorbAfter($afterAttribs); + /** @var array{0: class-string, 1: string} $listener */ + if ($this->isClassCallable($listener)) { + /** @var array{0: class-string, 1: string} $listener */ + [$class, $method] = $listener; - /** @var ListenerPriority|null $priorityAttrib */ - $priorityAttrib = $this->getAttributes(ListenerPriority::class, $ref)[0] ?? null; - if ($priorityAttrib) { - $def->absorbPriority($priorityAttrib); + $def = $this->classAnalyzer->analyze($class, Listener::class); + return $def->staticMethods[$method]; } - return $def; - } - - /** - * @param class-string $attribute - * @param \Reflector $ref - * @return array - */ - protected function getAttributes(string $attribute, \Reflector $ref): array - { - // The Reflector interface doesn't have getAttributes() defined, but - // it's always there. PHP bug. - // @phpstan-ignore-next-line - $attribs = $ref->getAttributes($attribute, \ReflectionAttribute::IS_INSTANCEOF); - return array_map(fn(\ReflectionAttribute $attrib) => $attrib->newInstance(), $attribs); + return new Listener(); } protected function deriveMethod(string $service): string @@ -279,7 +234,7 @@ protected function getType(callable $listener): string * generate a random ID if necessary. It will also handle duplicates * for us. This method is just a suggestion. * - * @param callable|array{0: string, 1: string} $listener + * @param callable|array{0: class-string, 1: string}|array{0: object, 1: string} $listener * The listener for which to derive an ID. * * @return string|null @@ -289,17 +244,19 @@ protected function getListenerId(callable|array $listener): ?string { if ($this->isFunctionCallable($listener)) { // Function callables are strings, so use that directly. - // @phpstan-ignore-next-line - return (string)$listener; + /** @var string $listener */ + return $listener; } - // @phpstan-ignore-next-line + + if ($this->isObjectCallable($listener)) { + /** @var array{0: object, 1: string} $listener */ + return get_class($listener[0]) . '::' . $listener[1]; + } + if ($this->isClassCallable($listener)) { /** @var array{0: class-string, 1: string} $listener */ return $listener[0] . '::' . $listener[1]; } - if (is_array($listener) && is_object($listener[0])) { - return get_class($listener[0]) . '::' . $listener[1]; - } // Anything else we can't derive an ID for logically. return null; @@ -310,7 +267,7 @@ protected function getListenerId(callable|array $listener): ?string * * Or at least a reasonable approximation, since a function name may not be defined yet. * - * @param callable|array{0: string, 1: string} $callable + * @param callable|array $callable * @return bool * True if the callable represents a function, false otherwise. */ @@ -323,10 +280,11 @@ protected function isFunctionCallable(callable|array $callable): bool /** * Determines if a callable represents a method on an object. * + * @param callable|array $callable * @return bool * True if the callable represents a method object, false otherwise. */ - protected function isObjectCallable(callable $callable): bool + protected function isObjectCallable(callable|array $callable): bool { return is_array($callable) && is_object($callable[0]); } @@ -334,13 +292,35 @@ protected function isObjectCallable(callable $callable): bool /** * Determines if a callable represents a closure/anonymous function. * + * @param callable|array $callable * @return bool * True if the callable represents a closure object, false otherwise. */ - protected function isClosureCallable(callable $callable): bool + protected function isClosureCallable(callable|array $callable): bool { return $callable instanceof \Closure; } + /** + * Determines if a callable represents a static class method. + * + * The parameter here is untyped so that this method may be called with an + * array that represents a class name and a non-static method. The routine + * to determine the parameter type is identical to a static method, but such + * an array is still not technically callable. Omitting the parameter type here + * allows us to use this method to handle both cases. + * + * This method must therefore be called first above, as the array is not actually + * an `is_callable()` and will fail `Closure::fromCallable()`. Because PHP. + * + * @param callable|array $callable + * @return bool + * True if the callable represents a static method, false otherwise. + */ + protected function isClassCallable($callable): bool + { + return is_array($callable) && is_string($callable[0]) && class_exists($callable[0]); + } + abstract protected function getListenerEntry(callable $listener, string $type): ListenerEntry; } diff --git a/tests/CompiledListenerProviderAttributeTest.php b/tests/CompiledListenerProviderAttributeTest.php index bbaacd3..1da76c7 100644 --- a/tests/CompiledListenerProviderAttributeTest.php +++ b/tests/CompiledListenerProviderAttributeTest.php @@ -33,23 +33,6 @@ function atNoListen(EventOne $event): void throw new \Exception('This should not be called'); } -class AtListen -{ - #[Listener] - public static function listen(CollectingEvent $event): void - { - $event->add('C'); - } -} - -class AtListenService -{ - public static function listen(CollectingEvent $event): void - { - $event->add('D'); - } -} - class CompiledListenerProviderAttributeTest extends TestCase { use MakeCompiledProviderTrait; @@ -63,13 +46,13 @@ public function compiled_provider_triggers_in_order(): void $builder = new ProviderBuilder(); $container = new MockContainer(); - $container->addService('D', new AtListenService()); + $container->addService('D', new Listeners\AtListenService()); $ns = "\\Crell\\Tukio"; $builder->addListener("{$ns}\\atListenerB"); $builder->addListener("{$ns}\\atListenerA"); - $builder->addListener([AtListen::class, 'listen']); + $builder->addListener([Listeners\AtListen::class, 'listen']); $builder->addListener("{$ns}\\atNoListen"); $provider = $this->makeProvider($builder, $container, $class, $namespace); @@ -99,7 +82,7 @@ public function add_subscriber(): void $subscriber = new MockAttributedSubscriber(); $container->addService('subscriber', $subscriber); - $builder->addSubscriber(MockSubscriber::class, 'subscriber'); + $builder->addSubscriber(MockAttributedSubscriber::class, 'subscriber'); $provider = $this->makeProvider($builder, $container, $class, $namespace); diff --git a/tests/CompiledListenerProviderTest.php b/tests/CompiledListenerProviderTest.php index 96a875b..3b2bf78 100644 --- a/tests/CompiledListenerProviderTest.php +++ b/tests/CompiledListenerProviderTest.php @@ -245,8 +245,6 @@ public function optimize_event_anonymous_class(): void self::assertEquals('BACD', implode($event->result())); } - - #[Test, DataProvider('detection_class_examples')] public function detects_invoke_method_and_type(string $class): void { diff --git a/tests/Listeners/AtListen.php b/tests/Listeners/AtListen.php new file mode 100644 index 0000000..6d958cc --- /dev/null +++ b/tests/Listeners/AtListen.php @@ -0,0 +1,15 @@ +add('C'); + } +} diff --git a/tests/Listeners/AtListenService.php b/tests/Listeners/AtListenService.php new file mode 100644 index 0000000..0b0f23a --- /dev/null +++ b/tests/Listeners/AtListenService.php @@ -0,0 +1,13 @@ +add('D'); + } +} diff --git a/tests/Listeners/InvokableListenerClassAttribute.php b/tests/Listeners/InvokableListenerClassAttribute.php new file mode 100644 index 0000000..1d952a8 --- /dev/null +++ b/tests/Listeners/InvokableListenerClassAttribute.php @@ -0,0 +1,15 @@ +add(static::class); + } +} diff --git a/tests/Listeners/MockMalformedSubscriber.php b/tests/Listeners/MockMalformedSubscriber.php index cf277d7..a02f669 100644 --- a/tests/Listeners/MockMalformedSubscriber.php +++ b/tests/Listeners/MockMalformedSubscriber.php @@ -16,6 +16,7 @@ public function onA(CollectingEvent $event): void { $event->add('A'); } + /** * This function should have automatic registration attempted, and fail due to missing a type. */ @@ -24,6 +25,7 @@ public function onNone($event): void { $event->add('A'); } + /** * This function should have manual registration attempted, and fail due to missing a type. */ @@ -39,12 +41,14 @@ public static function registerListenersDirect(ListenerProxy $proxy): void // Should fail and throw an exception: $proxy->addListener('abnormalNameWithoutType'); } + public static function registerListenersBefore(ListenerProxy $proxy): void { $a = $proxy->addListener('onA'); // Should fail and throw an exception: $proxy->addListenerBefore($a, 'abnormalNameWithoutType'); } + public static function registerListenersAfter(ListenerProxy $proxy): void { $a = $proxy->addListener('onA'); diff --git a/tests/OrderedListenerProviderIdTest.php b/tests/OrderedListenerProviderIdTest.php index 3a39500..d5d772f 100644 --- a/tests/OrderedListenerProviderIdTest.php +++ b/tests/OrderedListenerProviderIdTest.php @@ -137,14 +137,14 @@ public function listen(CollectingEvent $event): void $p = new OrderedListenerProvider($container); $idA = $p->addListenerService('A', 'listen', CollectingEvent::class, -4); - $p->addListenerServiceAfter('A-listen', 'B', 'listen', CollectingEvent::class); + $p->addListenerServiceAfter('A::listen', 'B', 'listen', CollectingEvent::class); $event = new CollectingEvent(); foreach ($p->getListenersForEvent($event) as $listener) { $listener($event); } - self::assertEquals('A-listen', $idA); + self::assertEquals('A::listen', $idA); self::assertEquals('AB', implode($event->result())); } diff --git a/tests/OrderedListenerProviderServiceTest.php b/tests/OrderedListenerProviderServiceTest.php index 611ef74..1e0287c 100644 --- a/tests/OrderedListenerProviderServiceTest.php +++ b/tests/OrderedListenerProviderServiceTest.php @@ -6,6 +6,7 @@ use Crell\Tukio\Events\CollectingEvent; use Crell\Tukio\Fakes\MockContainer; +use Crell\Tukio\Listeners\InvokableListenerClassAttribute; use Crell\Tukio\Listeners\MockMalformedSubscriber; use Crell\Tukio\Listeners\MockSubscriber; use PHPUnit\Framework\Attributes\DataProvider; @@ -176,11 +177,11 @@ public function malformed_subscriber_automatic_fails(): void $subscriber = new MockMalformedSubscriber(); - $container->addService('subscriber', $subscriber); + $container->addService(MockMalformedSubscriber::class, $subscriber); $p = new OrderedListenerProvider($container); - $p->addSubscriber(MockMalformedSubscriber::class, 'subscriber'); + $p->addSubscriber(MockMalformedSubscriber::class); } #[Test] @@ -266,6 +267,29 @@ public static function detection_class_examples(): iterable ]; } + #[Test] + public function detects_invoke_method_and_type_with_class_attribute(): void + { + $container = new MockContainer(); + + $container->addService(InvokableListenerClassAttribute::class, new InvokableListenerClassAttribute()); + + $provider = new OrderedListenerProvider($container); + + $provider->listenerService(InvokableListenerClassAttribute::class); + $provider->listener(fn(CollectingEvent $event) => $event->add('A'), priority: 10); + + $event = new CollectingEvent(); + + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + $results = $event->result(); + self::assertEquals('A', $results[0]); + self::assertEquals(InvokableListenerClassAttribute::class, $results[1]); + } + #[Test] public function rejects_multi_method_class_without_invoke(): void { @@ -287,7 +311,7 @@ public function rejects_missing_auto_detected_service(): void $provider = new OrderedListenerProvider($container); - /** @phpstan-ignore-next-line */ + // @phpstan-ignore-next-line $provider->listenerService(DoesNotExist::class); }