Milestone 4: Discovery & Registration (Attribute scanning & PSR-16 cache)

This commit is contained in:
Funky Waddle 2026-02-25 00:33:13 -06:00
parent 9f92227547
commit 26db488032
4 changed files with 367 additions and 0 deletions

29
src/AsEventListener.php Normal file
View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
use Attribute;
/**
* AsEventListener Attribute
*
* Marks a class or method as an event listener for a specific event.
* Used by the ListenerDiscovery service to automatically register listeners.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class AsEventListener
{
/**
* @param string $event The fully qualified class name of the event to listen for.
* @param int $priority The priority (higher runs first).
* @param string|null $method The method name to call (if applied to a class). Defaults to __invoke.
*/
public function __construct(
public string $event,
public int $priority = 0,
public ?string $method = null
) {
}
}

26
src/AsSubscriber.php Normal file
View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
use Attribute;
/**
* AsSubscriber Attribute
*
* Marks a class as an event subscriber.
* The class must implement SubscriberInterface to define its listeners.
* Used by the ListenerDiscovery service to automatically register subscribers.
*/
#[Attribute(Attribute::TARGET_CLASS)]
class AsSubscriber
{
/**
* @param int $priority Global priority for all listeners in this subscriber (if not overridden).
*/
public function __construct(
public int $priority = 0
) {
}
}

179
src/ListenerDiscovery.php Normal file
View file

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
use ReflectionClass;
use Psr\SimpleCache\CacheInterface;
/**
* ListenerDiscovery
*
* Scans directories for classes using Beacon attributes (#[AsEventListener], #[AsSubscriber]).
* Supports PSR-16 caching to prevent redundant filesystem scans.
*/
class ListenerDiscovery
{
/**
* @var string The cache key used for discovery results.
*/
private const CACHE_KEY = 'phred.beacon.discovery';
/**
* @param array<string> $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<int, array{event: string, listener: string|array{0: string, 1: string}, priority: int}>, subscribers: array<int, string>}
*/
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<int, array{event: string, listener: string|array{0: string, 1: string}, priority: int}>, subscribers: array<int, string>}
*/
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;
}
}

View file

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Phred\Beacon\AsEventListener;
use Phred\Beacon\AsSubscriber;
use Phred\Beacon\EventDispatcher;
use Phred\Beacon\ListenerDiscovery;
use Phred\Beacon\ListenerProvider;
use Phred\BeaconContracts\SubscriberInterface;
use Psr\Container\ContainerInterface;
class DiscoveryIntegrationTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->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', "<?php\n" . $content);
require_once $this->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);
}
}