diff --git a/src/AsEventListener.php b/src/AsEventListener.php new file mode 100644 index 0000000..a43eb4d --- /dev/null +++ b/src/AsEventListener.php @@ -0,0 +1,29 @@ + $directories List of absolute paths to scan. + * @param CacheInterface|null $cache Optional PSR-16 cache. + */ + public function __construct( + private readonly array $directories, + private readonly ?CacheInterface $cache = null + ) { + } + + /** + * Discovers listeners and registers them to the provider. + * + * @param ListenerProvider $provider + * @return void + */ + public function discover(ListenerProvider $provider): void + { + $map = $this->getDiscoveryMap(); + + foreach ($map['listeners'] as $item) { + $provider->addListener($item['event'], $item['listener'], $item['priority']); + } + + foreach ($map['subscribers'] as $subscriber) { + $provider->addSubscriber($subscriber); + } + } + + /** + * Gets the discovery map from cache or by scanning. + * + * @return array{listeners: array, subscribers: array} + */ + private function getDiscoveryMap(): array + { + if ($this->cache !== null && $this->cache->has(self::CACHE_KEY)) { + return $this->cache->get(self::CACHE_KEY); + } + + $map = $this->scan(); + + if ($this->cache !== null) { + $this->cache->set(self::CACHE_KEY, $map); + } + + return $map; + } + + /** + * Scans the configured directories for attributes. + * + * @return array{listeners: array, subscribers: array} + */ + private function scan(): array + { + $map = ['listeners' => [], 'subscribers' => []]; + + foreach ($this->directories as $directory) { + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)); + + foreach ($files as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $className = $this->extractClassName($file->getPathname()); + if ($className === null || !class_exists($className)) { + continue; + } + + $reflection = new ReflectionClass($className); + + // Check for #[AsSubscriber] + $subAttributes = $reflection->getAttributes(AsSubscriber::class); + if (!empty($subAttributes)) { + $map['subscribers'][] = $className; + } + + // Check for #[AsEventListener] on class + $classAttributes = $reflection->getAttributes(AsEventListener::class); + foreach ($classAttributes as $attr) { + /** @var AsEventListener $instance */ + $instance = $attr->newInstance(); + $map['listeners'][] = [ + 'event' => $instance->event, + 'listener' => $instance->method ? [$className, $instance->method] : $className, + 'priority' => $instance->priority, + ]; + } + + // Check for #[AsEventListener] on methods + foreach ($reflection->getMethods() as $method) { + $methodAttributes = $method->getAttributes(AsEventListener::class); + foreach ($methodAttributes as $attr) { + /** @var AsEventListener $instance */ + $instance = $attr->newInstance(); + $map['listeners'][] = [ + 'event' => $instance->event, + 'listener' => [$className, $method->getName()], + 'priority' => $instance->priority, + ]; + } + } + } + } + + return $map; + } + + /** + * Extracts the fully qualified class name from a file. + * Basic implementation using token_get_all. + * + * @param string $file + * @return string|null + */ + private function extractClassName(string $file): ?string + { + $content = file_get_contents($file); + $tokens = token_get_all($content); + $namespace = ''; + $class = ''; + $gettingNamespace = false; + $gettingClass = false; + + foreach ($tokens as $index => $token) { + if (is_array($token)) { + if ($token[0] === T_NAMESPACE) { + $gettingNamespace = true; + } elseif ($token[0] === T_CLASS) { + $gettingClass = true; + } + + if ($gettingNamespace) { + if ($token[0] === T_NAME_QUALIFIED || $token[0] === T_STRING) { + $namespace .= $token[1]; + } + } + + if ($gettingClass) { + if ($token[0] === T_STRING) { + $class = $token[1]; + break; + } + } + } else { + if ($token === ';' && $gettingNamespace) { + $gettingNamespace = false; + } + } + } + + $classFQCN = $namespace ? $namespace . '\\' . $class : $class; + return $classFQCN ?: null; + } +} diff --git a/tests/Integration/DiscoveryIntegrationTest.php b/tests/Integration/DiscoveryIntegrationTest.php new file mode 100644 index 0000000..4060b6f --- /dev/null +++ b/tests/Integration/DiscoveryIntegrationTest.php @@ -0,0 +1,133 @@ +tempDir = sys_get_temp_dir() . '/beacon_discovery_test_' . uniqid(); + mkdir($this->tempDir); + } + + protected function tearDown(): void + { + $this->removeDir($this->tempDir); + } + + public function test_it_discovers_attributed_listeners(): void + { + $this->createTestClass('TestListener', <<<'PHP' +namespace Phred\Discovery; +use Phred\Beacon\AsEventListener; +class TestListener { + #[AsEventListener(event: 'stdClass', priority: 10)] + public function onEvent($event): void { $event->called = true; } +} +PHP + ); + + $provider = new ListenerProvider(); + $discovery = new ListenerDiscovery([$this->tempDir]); + $discovery->discover($provider); + + $dispatcher = new EventDispatcher($provider); + $event = new \stdClass(); + $event->called = false; + + $dispatcher->dispatch($event); + + $this->assertTrue($event->called); + } + + public function test_it_discovers_subscribers(): void + { + $this->createTestClass('TestSubscriber', <<<'PHP' +namespace Phred\Discovery; +use Phred\Beacon\AsSubscriber; +use Phred\BeaconContracts\SubscriberInterface; +#[AsSubscriber] +class TestSubscriber implements SubscriberInterface { + public static function getSubscribedEvents(): array { + return ['stdClass' => 'onEvent']; + } + public function onEvent($event): void { $event->sub_called = true; } +} +PHP + ); + + $provider = new ListenerProvider(); + $discovery = new ListenerDiscovery([$this->tempDir]); + $discovery->discover($provider); + + $dispatcher = new EventDispatcher($provider); + $event = new \stdClass(); + $event->sub_called = false; + + $dispatcher->dispatch($event); + + $this->assertTrue($event->sub_called); + } + + public function test_lazy_loading_via_container(): void + { + $this->createTestClass('LazyListener', <<<'PHP' +namespace Phred\Discovery; +use Phred\Beacon\AsEventListener; +class LazyListener { + #[AsEventListener(event: 'stdClass')] + public function __invoke($event): void { $event->lazy_called = true; } +} +PHP + ); + + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->once()) + ->method('get') + ->with('Phred\Discovery\LazyListener') + ->willReturn(new class { + public function __invoke($event) { $event->lazy_called = true; } + }); + + $provider = new ListenerProvider($container); + $discovery = new ListenerDiscovery([$this->tempDir]); + $discovery->discover($provider); + + $dispatcher = new EventDispatcher($provider); + $event = new \stdClass(); + $event->lazy_called = false; + + $dispatcher->dispatch($event); + + $this->assertTrue($event->lazy_called); + } + + private function createTestClass(string $name, string $content): void + { + file_put_contents($this->tempDir . '/' . $name . '.php', "tempDir . '/' . $name . '.php'; + } + + private function removeDir(string $dir): void + { + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } +}