diff --git a/src/Core/Actions/ScheduledActionScanner.php b/src/Core/Actions/ScheduledActionScanner.php new file mode 100644 index 0000000..b5237ae --- /dev/null +++ b/src/Core/Actions/ScheduledActionScanner.php @@ -0,0 +1,186 @@ + $paths Directories to scan recursively + * @return array Map of class name to attribute instance + */ + public function scan(array $paths): array + { + $results = []; + + foreach ($paths as $path) { + if (! is_dir($path)) { + continue; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $class = $this->classFromFile($file->getPathname()); + + if ($class === null || ! class_exists($class)) { + continue; + } + + $attribute = $this->extractScheduled($class); + + if ($attribute !== null) { + $results[$class] = $attribute; + } + } + } + + return $results; + } + + /** + * Extract the #[Scheduled] attribute from a class. + */ + private function extractScheduled(string $class): ?Scheduled + { + try { + $ref = new ReflectionClass($class); + $attrs = $ref->getAttributes(Scheduled::class); + + if (empty($attrs)) { + return null; + } + + return $attrs[0]->newInstance(); + } catch (\ReflectionException) { + return null; + } + } + + /** + * Derive fully qualified class name from a PHP file. + * + * Reads the file's namespace declaration and class name. + */ + private function classFromFile(string $file): ?string + { + $contents = file_get_contents($file); + + if ($contents === false) { + return null; + } + + $namespace = null; + $class = null; + + foreach (token_get_all($contents) as $token) { + if (! is_array($token)) { + continue; + } + + if ($token[0] === T_NAMESPACE) { + $namespace = $this->extractNamespace($contents); + } + + if ($token[0] === T_CLASS) { + $class = $this->extractClassName($contents); + break; + } + } + + if ($class === null) { + return null; + } + + return $namespace !== null ? "{$namespace}\\{$class}" : $class; + } + + /** + * Extract the namespace string from file contents. + */ + private function extractNamespace(string $contents): ?string + { + $tokens = token_get_all($contents); + $capture = false; + $parts = []; + + foreach ($tokens as $token) { + if (is_array($token) && $token[0] === T_NAMESPACE) { + $capture = true; + + continue; + } + + if ($capture) { + if (is_array($token) && in_array($token[0], [T_NAME_QUALIFIED, T_STRING, T_NS_SEPARATOR], true)) { + $parts[] = $token[1]; + } elseif ($token === ';' || $token === '{') { + break; + } + } + } + + return ! empty($parts) ? implode('', $parts) : null; + } + + /** + * Extract class name from tokens after T_CLASS. + */ + private function extractClassName(string $contents): ?string + { + $tokens = token_get_all($contents); + $nextIsClass = false; + + foreach ($tokens as $token) { + if (is_array($token) && $token[0] === T_CLASS) { + $nextIsClass = true; + + continue; + } + + if ($nextIsClass && is_array($token)) { + if ($token[0] === T_WHITESPACE) { + continue; + } + if ($token[0] === T_STRING) { + return $token[1]; + } + + return null; + } + } + + return null; + } +} diff --git a/tests/Feature/ScheduledActionScannerTest.php b/tests/Feature/ScheduledActionScannerTest.php new file mode 100644 index 0000000..f37a725 --- /dev/null +++ b/tests/Feature/ScheduledActionScannerTest.php @@ -0,0 +1,73 @@ +scanner = new ScheduledActionScanner(); + } + + public function test_scan_discovers_scheduled_actions(): void + { + $results = $this->scanner->scan([ + dirname(__DIR__).'/Fixtures/Mod/Scheduled', + ]); + + $this->assertArrayHasKey(EveryMinuteAction::class, $results); + $this->assertArrayHasKey(DailyAction::class, $results); + } + + public function test_scan_ignores_non_scheduled_actions(): void + { + $results = $this->scanner->scan([ + dirname(__DIR__).'/Fixtures/Mod/Scheduled', + ]); + + $classes = array_keys($results); + foreach ($classes as $class) { + $this->assertStringNotContainsString('NotScheduled', $class); + } + } + + public function test_scan_returns_attribute_instances(): void + { + $results = $this->scanner->scan([ + dirname(__DIR__).'/Fixtures/Mod/Scheduled', + ]); + + $attr = $results[EveryMinuteAction::class]; + $this->assertInstanceOf(Scheduled::class, $attr); + $this->assertSame('everyMinute', $attr->frequency); + } + + public function test_scan_preserves_attribute_parameters(): void + { + $results = $this->scanner->scan([ + dirname(__DIR__).'/Fixtures/Mod/Scheduled', + ]); + + $attr = $results[DailyAction::class]; + $this->assertSame('dailyAt:09:00', $attr->frequency); + $this->assertSame('Europe/London', $attr->timezone); + $this->assertFalse($attr->withoutOverlapping); + } + + public function test_scan_handles_empty_directory(): void + { + $results = $this->scanner->scan(['/nonexistent/path']); + $this->assertEmpty($results); + } +} diff --git a/tests/Fixtures/Mod/Scheduled/Actions/DailyAction.php b/tests/Fixtures/Mod/Scheduled/Actions/DailyAction.php new file mode 100644 index 0000000..fa7f7de --- /dev/null +++ b/tests/Fixtures/Mod/Scheduled/Actions/DailyAction.php @@ -0,0 +1,19 @@ +