diff --git a/CHANGELOG.md b/CHANGELOG.md index a63ff559..cdca5811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,14 @@ * Add support for SSH connection without specifying a user * Marked SSH features as stable -* Deprecate `Castor\GlobalHelper` class. There are no replacements. Use raw - functions instead. * Import and load task from remote import automatically * Fix multiple remote imports of the same package with default version +* Do not load task from `vendor` directory * Add `context()` function in expression language to enable a task -* Fix import of local tasks when using remote import - +* Deprecate `Castor\GlobalHelper` class. There are no replacements. Use raw + functions instead +* Deprecate `AfterApplicationInitializationEvent` event. Use + `FunctionsResolvedEvent` instead. ## 0.15.0 (2024-04-03) diff --git a/bin/generate-tests.php b/bin/generate-tests.php index 27061232..ea739d07 100755 --- a/bin/generate-tests.php +++ b/bin/generate-tests.php @@ -18,10 +18,19 @@ $_SERVER['ENDPOINT'] ??= 'http://127.0.0.1:9955'; WebServerHelper::start(); +displayTitle('Cleaning'); + $fs = new Filesystem(); $fs->remove(PlatformHelper::getCacheDirectory()); $fs->remove(__DIR__ . '/../tests/Generated'); $fs->mkdir(__DIR__ . '/../tests/Generated'); +$fs->remove((new Finder()) + ->in(__DIR__ . '/../tests/fixtures') + ->in(__DIR__ . '/../tests/fixtures') + ->path('composer.installed') + ->ignoreDotFiles(false) +); +echo "\nDone.\n"; displayTitle('Retrieving example tasks'); diff --git a/doc/going-further/extending-castor/events.md b/doc/going-further/extending-castor/events.md index b922b43f..183eb3b0 100644 --- a/doc/going-further/extending-castor/events.md +++ b/doc/going-further/extending-castor/events.md @@ -11,9 +11,12 @@ You can register a listener inside your Castor project by using the the targeted event and the priority of this listener. ```php -#[AsListener(event: AfterApplicationInitializationEvent::class)] -#[AsListener(event: AfterExecuteTaskEvent::class, priority: 1)] -function my_event_listener(AfterApplicationInitializationEvent|AfterExecuteTaskEvent $event): void +use Castor\Event\AfterExecuteTaskEvent; +use Castor\Event\FunctionsResolvedEvent; + +#[AsListener(event: AfterExecuteTaskEvent::class)] +#[AsListener(event: FunctionsResolvedEvent::class, priority: 1)] +function my_event_listener(AfterExecuteTaskEvent|FunctionsResolvedEvent $event): void { // Custom logic to handle the events } @@ -30,9 +33,9 @@ function my_event_listener(AfterApplicationInitializationEvent|AfterExecuteTaskE Here is the built-in events triggered by Castor: -* `Castor\Event\AfterApplicationInitializationEvent`: This event is triggered - after the application has been initialized. It provides access to the - `Application` instance and an array of `TaskDescriptor` objects; +* `Castor\Event\FunctionsResolvedEvent`: This event is triggered after the + functions has been resolved. It provides access to an array of of + `TaskDescriptor` and `SymfonyTaskDescriptor` objects; * `Castor\Event\BeforeExecuteTaskEvent`: This event is triggered before executing a task. It provides access to the `TaskCommand` instance; diff --git a/src/Console/Application.php b/src/Console/Application.php index baac6e08..ede9fb37 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -2,28 +2,15 @@ namespace Castor\Console; -use Castor\Console\Command\SymfonyTaskCommand; -use Castor\Console\Output\VerbosityLevel; use Castor\Container; -use Castor\Context; -use Castor\ContextRegistry; -use Castor\Event\AfterApplicationInitializationEvent; -use Castor\Event\BeforeApplicationBootEvent; -use Castor\Event\BeforeApplicationInitializationEvent; -use Castor\Factory\TaskCommandFactory; -use Castor\Function\FunctionLoader; -use Castor\Helper\PlatformHelper; -use Castor\Import\Importer; -use Castor\Import\Kernel; +use Castor\Kernel; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Exception\ExceptionInterface; +use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** @internal */ class Application extends SymfonyApplication @@ -34,15 +21,8 @@ class Application extends SymfonyApplication private Command $command; public function __construct( - private readonly string $rootDir, private readonly ContainerBuilder $containerBuilder, - private readonly EventDispatcherInterface $eventDispatcher, - #[Autowire(lazy: true)] - private readonly Importer $importer, - private readonly FunctionLoader $functionLoader, private readonly Kernel $kernel, - private readonly ContextRegistry $contextRegistry, - private readonly TaskCommandFactory $taskCommandFactory, ) { parent::__construct(static::NAME, static::VERSION); } @@ -55,8 +35,6 @@ public function getCommand(bool $allowNull = false): ?Command return $this->command ?? ($allowNull ? null : throw new \LogicException('Command not available yet.')); } - // We do all the logic as late as possible to ensure the exception handler - // is registered public function doRun(InputInterface $input, OutputInterface $output): int { $this->containerBuilder->set(InputInterface::class, $input); @@ -65,39 +43,7 @@ public function doRun(InputInterface $input, OutputInterface $output): int // @phpstan-ignore-next-line Container::set($this->containerBuilder->get(Container::class)); - $this->eventDispatcher->dispatch(new BeforeApplicationBootEvent($this)); - - $currentFunctions = get_defined_functions()['user']; - $currentClasses = get_declared_classes(); - - $functionsRootDir = $this->rootDir; - if (class_exists(\RepackedApplication::class)) { - $functionsRootDir = \RepackedApplication::ROOT_DIR; - } - $this->importer->require($functionsRootDir); - - $this->eventDispatcher->dispatch(new BeforeApplicationInitializationEvent($this)); - - $taskDescriptorCollection = $this->functionLoader->load($currentFunctions, $currentClasses); - $taskDescriptorCollection = $taskDescriptorCollection->merge($this->kernel->mount()); - - $this->initializeApplication($input); - - // Must be done after the initializeApplication() call, to ensure all - // contexts have been created; but before the adding of task, because we - // may want to seek in the context to know if the command is enabled - $this->configureContext($input, $output); - - $event = new AfterApplicationInitializationEvent($this, $taskDescriptorCollection); - $this->eventDispatcher->dispatch($event); - $taskDescriptorCollection = $event->taskDescriptorCollection; - - foreach ($taskDescriptorCollection->taskDescriptors as $taskDescriptor) { - $this->add($this->taskCommandFactory->createTask($taskDescriptor)); - } - foreach ($taskDescriptorCollection->symfonyTaskDescriptors as $symfonyTaskDescriptor) { - $this->add(SymfonyTaskCommand::createFromDescriptor($symfonyTaskDescriptor)); - } + $this->kernel->boot($input, $output); return parent::doRun($input, $output); } @@ -109,26 +55,11 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI return parent::doRunCommand($command, $input, $output); } - private function initializeApplication(InputInterface $input): void + protected function getDefaultInputDefinition(): InputDefinition { - $this->contextRegistry->setDefaultIfEmpty(); - - $contextNames = $this->contextRegistry->getNames(); - - if ($contextNames) { - $defaultContext = PlatformHelper::getEnv('CASTOR_CONTEXT') ?: $this->contextRegistry->getDefaultName(); + $definition = parent::getDefaultInputDefinition(); - $this->getDefinition()->addOption(new InputOption( - 'context', - '_complete' === $input->getFirstArgument() || 'list' === $input->getFirstArgument() ? null : 'c', - InputOption::VALUE_REQUIRED, - sprintf('The context to use (%s)', implode('|', $contextNames)), - $defaultContext, - $contextNames, - )); - } - - $this->getDefinition()->addOption( + $definition->addOption( new InputOption( 'no-remote', null, @@ -137,7 +68,7 @@ private function initializeApplication(InputInterface $input): void ) ); - $this->getDefinition()->addOption( + $definition->addOption( new InputOption( 'update-remotes', null, @@ -145,33 +76,7 @@ private function initializeApplication(InputInterface $input): void 'Force the update of remote packages', ) ); - } - private function configureContext(InputInterface $input, OutputInterface $output): void - { - try { - $input->bind($this->getDefinition()); - } catch (ExceptionInterface) { - // not an issue if parsing gone wrong, we'll just use the default - // context and it will fail later anyway - } - - // occurs when running `castor -h`, or if no context is defined - if (!$input->hasOption('context')) { - $this->contextRegistry->setCurrentContext(new Context()); - - return; - } - - $context = $this - ->contextRegistry - ->get($input->getOption('context')) - ; - - if ($context->verbosityLevel->isNotConfigured()) { - $context = $context->withVerbosityLevel(VerbosityLevel::fromSymfonyOutput($output)); - } - - $this->contextRegistry->setCurrentContext($context->withName($input->getOption('context'))); + return $definition; } } diff --git a/src/Console/ApplicationFactory.php b/src/Console/ApplicationFactory.php index 21b683a8..8fef0098 100644 --- a/src/Console/ApplicationFactory.php +++ b/src/Console/ApplicationFactory.php @@ -6,6 +6,7 @@ use Castor\Console\Command\DebugCommand; use Castor\Console\Command\RepackCommand; use Castor\Container; +use Castor\Descriptor\DescriptorsCollection; use Castor\Event\AfterApplicationInitializationEvent; use Castor\Helper\PathHelper; use Castor\Helper\PlatformHelper; @@ -78,6 +79,7 @@ private static function configureDebug(): ErrorHandler AbstractCloner::$defaultCasters[self::class] = ['Symfony\Component\VarDumper\Caster\StubCaster', 'cutInternals']; AbstractCloner::$defaultCasters[AfterApplicationInitializationEvent::class] = ['Symfony\Component\VarDumper\Caster\StubCaster', 'cutInternals']; + AbstractCloner::$defaultCasters[DescriptorsCollection::class] = ['Symfony\Component\VarDumper\Caster\StubCaster', 'cutInternals']; return $errorHandler; } diff --git a/src/Console/Command/RepackCommand.php b/src/Console/Command/RepackCommand.php index 7ce29b5b..7764829d 100644 --- a/src/Console/Command/RepackCommand.php +++ b/src/Console/Command/RepackCommand.php @@ -2,18 +2,26 @@ namespace Castor\Console\Command; -use Castor\Function\FunctionFinder; use Castor\Helper\PathHelper; +use Castor\Import\Importer; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Process\ExecutableFinder; use Symfony\Component\Process\Process; /** @internal */ class RepackCommand extends Command { + public function __construct( + #[Autowire(lazy: true)] + private readonly Importer $importer, + ) { + parent::__construct(); + } + protected function configure(): void { $this @@ -94,7 +102,7 @@ class RepackedApplication extends Application $boxConfig['files'] = [ ...array_map( fn (string $file): string => str_replace(PathHelper::getRoot() . '/', '', $file), - FunctionFinder::$files, + $this->importer->getImports(), ), ...$boxConfig['files'] ?? [], ]; diff --git a/src/Container.php b/src/Container.php index ca908495..141d1d8a 100644 --- a/src/Container.php +++ b/src/Container.php @@ -5,11 +5,9 @@ use Castor\Console\Application; use Castor\Console\Output\SectionOutput; use Castor\Fingerprint\FingerprintHelper; -use Castor\Function\FunctionFinder; use Castor\Helper\Notifier; use Castor\Helper\Waiter; use Castor\Import\Importer; -use Castor\Import\Kernel; use Castor\Runner\ParallelRunner; use Castor\Runner\ProcessRunner; use Castor\Runner\SshRunner; @@ -38,7 +36,6 @@ public function __construct( public readonly EventDispatcherInterface $eventDispatcher, public readonly Filesystem $fs, public readonly FingerprintHelper $fingerprintHelper, - public readonly FunctionFinder $functionFinder, public readonly HttpClientInterface $httpClient, public readonly Importer $importer, public readonly InputInterface $input, diff --git a/src/Descriptor/DescriptorsCollection.php b/src/Descriptor/DescriptorsCollection.php new file mode 100644 index 00000000..f2900cba --- /dev/null +++ b/src/Descriptor/DescriptorsCollection.php @@ -0,0 +1,22 @@ + $contextDescriptors + * @param list $contextGeneratorDescriptors + * @param list $listenerDescriptors + * @param list $taskDescriptors + * @param list $symfonyTaskDescriptors + */ + public function __construct( + public readonly array $contextDescriptors, + public readonly array $contextGeneratorDescriptors, + public readonly array $listenerDescriptors, + public readonly array $taskDescriptors, + public readonly array $symfonyTaskDescriptors, + ) { + } +} diff --git a/src/Descriptor/ListenerDescriptor.php b/src/Descriptor/ListenerDescriptor.php index 540f2cbe..28b3e3c3 100644 --- a/src/Descriptor/ListenerDescriptor.php +++ b/src/Descriptor/ListenerDescriptor.php @@ -4,6 +4,7 @@ use Castor\Attribute\AsListener; +/** @internal */ class ListenerDescriptor { public function __construct( diff --git a/src/Descriptor/SymfonyTaskDescriptor.php b/src/Descriptor/SymfonyTaskDescriptor.php index 300063a8..d3cecf3c 100644 --- a/src/Descriptor/SymfonyTaskDescriptor.php +++ b/src/Descriptor/SymfonyTaskDescriptor.php @@ -4,7 +4,6 @@ use Castor\Attribute\AsSymfonyTask; -/** @internal */ class SymfonyTaskDescriptor { /** diff --git a/src/Descriptor/TaskDescriptor.php b/src/Descriptor/TaskDescriptor.php index c535262e..7a32743b 100644 --- a/src/Descriptor/TaskDescriptor.php +++ b/src/Descriptor/TaskDescriptor.php @@ -4,7 +4,6 @@ use Castor\Attribute\AsTask; -/** @internal */ class TaskDescriptor { public function __construct( diff --git a/src/Descriptor/TaskDescriptorCollection.php b/src/Descriptor/TaskDescriptorCollection.php index aa64f3eb..2511df57 100644 --- a/src/Descriptor/TaskDescriptorCollection.php +++ b/src/Descriptor/TaskDescriptorCollection.php @@ -2,23 +2,20 @@ namespace Castor\Descriptor; +trigger_deprecation('castor', '0.16', 'The "%s" class is deprecated, use "%s" instead.', TaskDescriptorCollection::class, DescriptorsCollection::class); + +/** + * @deprecated since Castor 0.16, use DescriptorsCollection instead + */ class TaskDescriptorCollection { /** - * @param TaskDescriptor[] $taskDescriptors - * @param SymfonyTaskDescriptor[] $symfonyTaskDescriptors + * @param list $taskDescriptors + * @param list $symfonyTaskDescriptors */ public function __construct( public readonly array $taskDescriptors = [], public readonly array $symfonyTaskDescriptors = [], ) { } - - public function merge(self $other): self - { - return new self( - [...$this->taskDescriptors, ...$other->taskDescriptors], - [...$this->symfonyTaskDescriptors, ...$other->symfonyTaskDescriptors], - ); - } } diff --git a/src/Event/AfterApplicationInitializationEvent.php b/src/Event/AfterApplicationInitializationEvent.php index 9d1a41b4..8e767c75 100644 --- a/src/Event/AfterApplicationInitializationEvent.php +++ b/src/Event/AfterApplicationInitializationEvent.php @@ -5,6 +5,9 @@ use Castor\Console\Application; use Castor\Descriptor\TaskDescriptorCollection; +/** + * @deprecated since Castor 0.16, use FunctionsResolvedEvent instead + */ class AfterApplicationInitializationEvent { public function __construct( diff --git a/src/Event/BeforeApplicationInitializationEvent.php b/src/Event/BeforeApplicationInitializationEvent.php deleted file mode 100644 index d9641b41..00000000 --- a/src/Event/BeforeApplicationInitializationEvent.php +++ /dev/null @@ -1,14 +0,0 @@ - $taskDescriptors + * @param list $symfonyTaskDescriptors + */ + public function __construct( + public array $taskDescriptors, + public array $symfonyTaskDescriptors, + ) { + } +} diff --git a/src/Exception/CouldNotFindEntrypointException.php b/src/Exception/CouldNotFindEntrypointException.php new file mode 100644 index 00000000..903b326d --- /dev/null +++ b/src/Exception/CouldNotFindEntrypointException.php @@ -0,0 +1,13 @@ + $previousFunctions - * @param list $previousClasses + * @param list $contextDescriptors + * @param list $contextGeneratorDescriptors */ - public function load(array $previousFunctions, array $previousClasses): TaskDescriptorCollection + public function loadContexts(array $contextDescriptors, array $contextGeneratorDescriptors): void { - $descriptors = $this - ->functionFinder - ->findFunctions($previousFunctions, $previousClasses) - ; - - return $this->loadFunctions($descriptors); + foreach ($contextDescriptors as $descriptor) { + $this->contextRegistry->addDescriptor($descriptor); + } + foreach ($contextGeneratorDescriptors as $descriptor) { + foreach ($descriptor->generators as $name => $generator) { + $this->contextRegistry->addContext($name, $generator); + } + } } /** - * @param iterable $descriptors + * @param list $listenerDescriptors */ - private function loadFunctions(iterable $descriptors): TaskDescriptorCollection + public function loadListeners(array $listenerDescriptors): void { - $tasks = []; - $symfonyTasks = []; - foreach ($descriptors as $descriptor) { - if ($descriptor instanceof TaskDescriptor) { - $tasks[] = $descriptor; - } elseif ($descriptor instanceof SymfonyTaskDescriptor) { - $symfonyTasks[] = $descriptor; - } elseif ($descriptor instanceof ContextDescriptor) { - $this->contextRegistry->addDescriptor($descriptor); - } elseif ($descriptor instanceof ContextGeneratorDescriptor) { - foreach ($descriptor->generators as $name => $generator) { - $this->contextRegistry->addContext($name, $generator); - } - } elseif ($descriptor instanceof ListenerDescriptor && null !== $descriptor->reflectionFunction->getClosure()) { - $this->eventDispatcher->addListener( - $descriptor->asListener->event, - $descriptor->reflectionFunction->getClosure(), - $descriptor->asListener->priority - ); - } + foreach ($listenerDescriptors as $descriptor) { + $this->eventDispatcher->addListener( + $descriptor->asListener->event, + // @phpstan-ignore-next-line + $descriptor->reflectionFunction->getClosure(), + $descriptor->asListener->priority + ); } + } - return new TaskDescriptorCollection($tasks, $symfonyTasks); + /** + * @param list $taskDescriptors + * @param list $symfonyTaskDescriptors + */ + public function loadTasks( + array $taskDescriptors, + array $symfonyTaskDescriptors, + ): void { + foreach ($taskDescriptors as $descriptor) { + $this->application->add($this->taskCommandFactory->createTask($descriptor)); + } + foreach ($symfonyTaskDescriptors as $descriptor) { + $this->application->add(SymfonyTaskCommand::createFromDescriptor($descriptor)); + } } } diff --git a/src/Function/FunctionFinder.php b/src/Function/FunctionResolver.php similarity index 84% rename from src/Function/FunctionFinder.php rename to src/Function/FunctionResolver.php index 1549904a..f53808b9 100644 --- a/src/Function/FunctionFinder.php +++ b/src/Function/FunctionResolver.php @@ -9,6 +9,7 @@ use Castor\Attribute\AsTask; use Castor\Descriptor\ContextDescriptor; use Castor\Descriptor\ContextGeneratorDescriptor; +use Castor\Descriptor\DescriptorsCollection; use Castor\Descriptor\ListenerDescriptor; use Castor\Descriptor\SymfonyTaskDescriptor; use Castor\Descriptor\TaskDescriptor; @@ -21,11 +22,8 @@ use Symfony\Contracts\Cache\ItemInterface; /** @internal */ -final class FunctionFinder +final class FunctionResolver { - /** @var array */ - public static array $files = []; - public function __construct( private readonly Slugger $slugger, private readonly CacheInterface $cache, @@ -33,13 +31,48 @@ public function __construct( ) { } + /** + * @param list $previousFunctions + * @param list $previousClasses + */ + public function resolveFunctions(array $previousFunctions, array $previousClasses): DescriptorsCollection + { + $contextDescriptors = []; + $contextGeneratorDescriptors = []; + $taskDescriptors = []; + $symfonyTaskDescriptors = []; + $listenerDescriptors = []; + + foreach ($this->doResolveFunctions($previousFunctions, $previousClasses) as $descriptor) { + if ($descriptor instanceof TaskDescriptor) { + $taskDescriptors[] = $descriptor; + } elseif ($descriptor instanceof SymfonyTaskDescriptor) { + $symfonyTaskDescriptors[] = $descriptor; + } elseif ($descriptor instanceof ContextDescriptor) { + $contextDescriptors[] = $descriptor; + } elseif ($descriptor instanceof ContextGeneratorDescriptor) { + $contextGeneratorDescriptors[] = $descriptor; + } elseif ($descriptor instanceof ListenerDescriptor) { + $listenerDescriptors[] = $descriptor; + } + } + + return new DescriptorsCollection( + $contextDescriptors, + $contextGeneratorDescriptors, + $listenerDescriptors, + $taskDescriptors, + $symfonyTaskDescriptors, + ); + } + /** * @param list $previousFunctions * @param list $previousClasses * * @return iterable */ - public function findFunctions(array $previousFunctions, array $previousClasses): iterable + private function doResolveFunctions(array $previousFunctions, array $previousClasses): iterable { $newFunctions = array_diff(get_defined_functions()['user'], $previousFunctions); foreach ($newFunctions as $functionName) { diff --git a/src/Import/Importer.php b/src/Import/Importer.php index 54b92efb..3e1c7586 100644 --- a/src/Import/Importer.php +++ b/src/Import/Importer.php @@ -15,6 +15,11 @@ /** @internal */ class Importer { + /** + * @var array + */ + private array $imports = []; + public function __construct( private readonly PackageImporter $packageImporter, private readonly LoggerInterface $logger, @@ -35,7 +40,7 @@ public function import(string $path, ?string $file = null, ?string $version = nu $package = mb_substr($path, mb_strlen($scheme) + 3); try { - $this->packageImporter->importPackage( + $this->packageImporter->addPackage( $scheme, $package, $file, @@ -61,47 +66,39 @@ public function import(string $path, ?string $file = null, ?string $version = nu } if (is_file($path)) { - castor_require($path); + $this->importFile($path); } if (is_dir($path)) { $files = Finder::create() ->files() ->name('*.php') - ->notPath('/vendor\/composer/') - ->notName('autoload.php') + ->notPath('vendor') ->in($path) ; foreach ($files as $file) { - castor_require($file->getPathname()); + $this->importFile($file->getPathname()); } } } - public function require(string $path): void + public function importFile(string $file): void { - if (file_exists($file = $path . '/castor.php')) { - castor_require($file); - } elseif (file_exists($file = $path . '/.castor/castor.php')) { - castor_require($file); - } else { - throw new \RuntimeException('Could not find root "castor.php" file.'); + if (isset($this->imports[$file])) { + return; } + $this->imports[$file] = true; - $castorDirectory = $path . '/castor'; - if (is_dir($castorDirectory)) { - trigger_deprecation('castor', '0.15', 'Autoloading functions from the "/castor/" directory is deprecated. Import files by yourself with the "castor\import()" function.'); - $files = Finder::create() - ->files() - ->name('*.php') - ->in($castorDirectory) - ; + castor_require($file); + } - foreach ($files as $file) { - castor_require($file->getPathname()); - } - } + /** + * @return list + */ + public function getImports(): array + { + return array_keys($this->imports); } private function getImportLocatedMessage(string $path, string $reason, int $depth): string diff --git a/src/Import/Kernel.php b/src/Import/Kernel.php deleted file mode 100644 index 86315357..00000000 --- a/src/Import/Kernel.php +++ /dev/null @@ -1,56 +0,0 @@ - $mounts - */ - public function __construct( - #[Autowire(lazy: true)] - private readonly Importer $importer, - private readonly FunctionLoader $functionLoader, - private array $mounts = [], - ) { - } - - public function addMount(Mount $mount): void - { - $this->mounts[] = $mount; - } - - public function mount(): TaskDescriptorCollection - { - $taskDescriptorCollection = new TaskDescriptorCollection(); - foreach ($this->mounts as $mount) { - $currentFunctions = get_defined_functions()['user']; - $currentClasses = get_declared_classes(); - - $this->importer->require($mount->path); - - $taskDescriptorCollectionTmp = $this->functionLoader->load($currentFunctions, $currentClasses); - - foreach ($taskDescriptorCollectionTmp->taskDescriptors as $descriptor) { - $descriptor->workingDirectory = $mount->path; - if ($mount->namespacePrefix) { - if ($descriptor->taskAttribute->namespace) { - $descriptor->taskAttribute->namespace = $mount->namespacePrefix . ':' . $descriptor->taskAttribute->namespace; - } else { - $descriptor->taskAttribute->namespace = $mount->namespacePrefix; - } - } - } - - $taskDescriptorCollection = $taskDescriptorCollection->merge($taskDescriptorCollectionTmp); - } - $this->mounts = []; - - return $taskDescriptorCollection; - } -} diff --git a/src/Import/Listener/RemoteImportListener.php b/src/Import/Listener/RemoteImportListener.php deleted file mode 100644 index 5717d9e7..00000000 --- a/src/Import/Listener/RemoteImportListener.php +++ /dev/null @@ -1,22 +0,0 @@ -packageImporter->fetchPackages(); - } -} diff --git a/src/Import/Mount.php b/src/Import/Mount.php index a8d79e03..5997e9db 100644 --- a/src/Import/Mount.php +++ b/src/Import/Mount.php @@ -10,6 +10,7 @@ class Mount { public function __construct( public readonly string $path, + public readonly bool $allowEmptyEntrypoint = false, public readonly ?string $namespacePrefix = null, ) { } diff --git a/src/Import/Remote/Composer.php b/src/Import/Remote/Composer.php index 6652a60e..48cea13a 100644 --- a/src/Import/Remote/Composer.php +++ b/src/Import/Remote/Composer.php @@ -3,7 +3,6 @@ namespace Castor\Import\Remote; use Castor\Console\Application; -use Castor\Fingerprint\FingerprintHelper; use Castor\Helper\PathHelper; use Castor\Import\Exception\ComposerError; use Psr\Log\LoggerInterface; @@ -31,7 +30,6 @@ class Composer public function __construct( private readonly Filesystem $filesystem, private readonly OutputInterface $output, - private readonly FingerprintHelper $fingerprintHelper, /** @var array */ private array $configuration = self::DEFAULT_COMPOSER_CONFIGURATION, private readonly LoggerInterface $logger = new NullLogger(), @@ -68,12 +66,11 @@ public function update(bool $force = false, bool $displayProgress = true): void file_put_contents($dir . '.gitignore', "*\n"); - $this->writeJsonFile($dir . 'composer.json', $this->configuration); + $this->writeJsonFile($dir); $ran = false; - $fingerprint = base64_encode(json_encode($this->configuration, \JSON_THROW_ON_ERROR)); - if ($force || !$this->fingerprintHelper->verifyFingerprintFromHash($fingerprint)) { + if ($force || !$this->isInstalled($dir)) { $progressIndicator = null; if ($displayProgress) { $progressIndicator = new ProgressIndicator($this->output, null, 100, ['⠏', '⠛', '⠹', '⢸', '⣰', '⣤', '⣆', '⡇']); @@ -89,7 +86,7 @@ public function update(bool $force = false, bool $displayProgress = true): void if ($progressIndicator) { $progressIndicator->finish('Remote packages imported'); } - $this->fingerprintHelper->postProcessFingerprintForHash($fingerprint); + $this->writeInstalled($dir); $ran = true; } @@ -131,11 +128,20 @@ private function run(array $args, callable $callback): void ]); } - /** - * @param array $json - */ - private function writeJsonFile(string $path, array $json): void + private function writeJsonFile(string $path): void + { + file_put_contents("{$path}/composer.json", json_encode($this->configuration, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR)); + } + + private function writeInstalled(string $path): void { - file_put_contents($path, json_encode($json, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR)); + file_put_contents("{$path}/composer.installed", hash('sha256', json_encode($this->configuration, \JSON_THROW_ON_ERROR))); + } + + private function isInstalled(string $path): bool + { + $path = "{$path}/composer.installed"; + + return file_exists($path) && file_get_contents($path) === hash('sha256', json_encode($this->configuration, \JSON_THROW_ON_ERROR)); } } diff --git a/src/Import/Remote/PackageImporter.php b/src/Import/Remote/PackageImporter.php index d9276d33..753a04fe 100644 --- a/src/Import/Remote/PackageImporter.php +++ b/src/Import/Remote/PackageImporter.php @@ -6,27 +6,26 @@ use Castor\Import\Exception\ImportError; use Castor\Import\Exception\InvalidImportFormat; use Castor\Import\Exception\RemoteNotAllowed; -use Castor\Import\Importer; +use Castor\Import\Mount; +use Castor\Kernel; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\DependencyInjection\Attribute\Autowire; /** @internal */ class PackageImporter { public function __construct( - #[Autowire(lazy: true)] - private readonly Importer $importer, private readonly InputInterface $input, private readonly LoggerInterface $logger, private readonly Composer $composer, + private readonly Kernel $kernel, /** @var array */ private array $imports = [], ) { } /** @phpstan-param ImportSource $source */ - public function importPackage(string $scheme, string $package, ?string $file = null, ?string $version = null, ?string $vcs = null, ?array $source = null): void + public function addPackage(string $scheme, string $package, ?string $file = null, ?string $version = null, ?string $vcs = null, ?array $source = null): void { if (!$this->allowsRemote()) { throw new RemoteNotAllowed('Remote imports are disabled.'); @@ -47,7 +46,7 @@ public function importPackage(string $scheme, string $package, ?string $file = n throw new InvalidImportFormat('The "source" argument is not supported for Composer/Packagist packages.'); } - $this->importPackageWithComposer($package, version: $requiredVersion, repositoryUrl: $vcs, file: $file); + $this->addPackageWithComposer($package, version: $requiredVersion, repositoryUrl: $vcs, file: $file); return; } @@ -60,7 +59,7 @@ public function importPackage(string $scheme, string $package, ?string $file = n throw new InvalidImportFormat('The "source" argument is required for non-Composer packages.'); } - $this->importPackageWithComposer($package, version: 'v1', source: $source, file: $file); + $this->addPackageWithComposer($package, version: 'v1', source: $source, file: $file); return; } @@ -68,12 +67,10 @@ public function importPackage(string $scheme, string $package, ?string $file = n throw new InvalidImportFormat(sprintf('The import scheme "%s" is not supported.', $scheme)); } - public function fetchPackages(): void + public function fetchPackages(): bool { if (!$this->imports) { - $this->composer->remove(); - - return; + return false; } // Need to look for the raw options as the input is not yet parsed @@ -92,9 +89,21 @@ public function fetchPackages(): void foreach ($this->imports as $package => $import) { foreach ($import->getFiles() as $file) { - $this->importer->import(PathHelper::getRoot() . Composer::VENDOR_DIR . $package . '/' . ($file ?? '')); + $this->kernel->addMount(new Mount( + PathHelper::getRoot() . Composer::VENDOR_DIR . $package . '/' . ($file ?? ''), + allowEmptyEntrypoint: true, + )); } } + + $this->imports = []; + + return true; + } + + public function clean(): void + { + $this->composer->remove(); } /** @@ -104,7 +113,7 @@ public function fetchPackages(): void * reference?: string, * } $source */ - private function importPackageWithComposer(string $package, string $version, ?string $repositoryUrl = null, ?array $source = null, ?string $file = null): void + private function addPackageWithComposer(string $package, string $version, ?string $repositoryUrl = null, ?array $source = null, ?string $file = null): void { $this->logger->info('Importing remote package with Composer.', [ 'package' => $package, diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 00000000..652421b9 --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,230 @@ + + */ + private array $mounts = []; + + public function __construct( + #[Autowire(lazy: true)] + private readonly Application $application, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly string $rootDir, + #[Autowire(lazy: true)] + private readonly Importer $importer, + #[Autowire(lazy: true)] + private readonly PackageImporter $packageImporter, + private readonly FunctionResolver $functionResolver, + private readonly FunctionLoader $functionLoader, + private readonly ContextRegistry $contextRegistry, + ) { + } + + public function boot(InputInterface $input, OutputInterface $output): void + { + $this->eventDispatcher->dispatch(new BeforeBootEvent($this->application)); + + $functionsRootDir = $this->rootDir; + if (class_exists(\RepackedApplication::class)) { + $functionsRootDir = \RepackedApplication::ROOT_DIR; + } + + $this->addMount(new Mount($functionsRootDir)); + + $hasLoadedPackages = false; + + while ($mount = array_shift($this->mounts)) { + $currentFunctions = get_defined_functions()['user']; + $currentClasses = get_declared_classes(); + + $this->load($mount, $currentFunctions, $currentClasses, $input, $output); + + if ($this->packageImporter->fetchPackages()) { + $hasLoadedPackages = true; + } + } + + if (!$hasLoadedPackages) { + $this->packageImporter->clean(); + } + } + + public function addMount(Mount $mount): void + { + $this->mounts[] = $mount; + } + + /** + * @param list $currentFunctions + * @param list $currentClasses + */ + private function load( + Mount $mount, + array $currentFunctions, + array $currentClasses, + InputInterface $input, + OutputInterface $output + ): void { + try { + $this->requireEntrypoint($mount->path); + } catch (CouldNotFindEntrypointException $e) { + if (!$mount->allowEmptyEntrypoint) { + throw $e; + } + } + + $descriptorsCollection = $this->functionResolver->resolveFunctions($currentFunctions, $currentClasses); + + // Apply mounts + foreach ($descriptorsCollection->taskDescriptors as $taskDescriptor) { + if ($mount->path) { + $taskDescriptor->workingDirectory = $mount->path; + } + if ($mount->namespacePrefix) { + if ($taskDescriptor->taskAttribute->namespace) { + $taskDescriptor->taskAttribute->namespace = $mount->namespacePrefix . ':' . $taskDescriptor->taskAttribute->namespace; + } else { + $taskDescriptor->taskAttribute->namespace = $mount->namespacePrefix; + } + } + } + + // Must load contexts before tasks, because tasks can be disabled + // depending on the context. And it must be before the listener too, to + // get the context there. + $this->functionLoader->loadContexts($descriptorsCollection->contextDescriptors, $descriptorsCollection->contextGeneratorDescriptors); + $this->configureContext($input, $output); + + $this->functionLoader->loadListeners($descriptorsCollection->listenerDescriptors); + + if ($this->eventDispatcher->hasListeners(AfterApplicationInitializationEvent::class)) { + trigger_deprecation('castor', '0.16', 'The "%s" class is deprecated, use "%s" instead.', AfterApplicationInitializationEvent::class, FunctionsResolvedEvent::class); + $event = new AfterApplicationInitializationEvent( + $this->application, + new TaskDescriptorCollection( + $descriptorsCollection->taskDescriptors, + $descriptorsCollection->symfonyTaskDescriptors + ), + ); + $this->eventDispatcher->dispatch($event); + $taskDescriptorCollection = $event->taskDescriptorCollection; + + $descriptorsCollection = new DescriptorsCollection( + $descriptorsCollection->contextDescriptors, + $descriptorsCollection->contextGeneratorDescriptors, + $descriptorsCollection->listenerDescriptors, + $taskDescriptorCollection->taskDescriptors, + $taskDescriptorCollection->symfonyTaskDescriptors, + ); + } + + $event = new FunctionsResolvedEvent( + $descriptorsCollection->taskDescriptors, + $descriptorsCollection->symfonyTaskDescriptors + ); + $this->eventDispatcher->dispatch($event); + + $this->functionLoader->loadTasks( + $event->taskDescriptors, + $event->symfonyTaskDescriptors + ); + } + + private function requireEntrypoint(string $path): void + { + if (file_exists($file = $path . '/castor.php')) { + $this->importer->importFile($file); + } elseif (file_exists($file = $path . '/.castor/castor.php')) { + $this->importer->importFile($file); + } else { + throw new CouldNotFindEntrypointException(); + } + + $castorDirectory = $path . '/castor'; + if (is_dir($castorDirectory)) { + trigger_deprecation('castor', '0.15', 'Autoloading functions from the "/castor/" directory is deprecated. Import files by yourself with the "castor\import()" function.'); + $files = Finder::create() + ->files() + ->name('*.php') + ->in($castorDirectory) + ; + + foreach ($files as $file) { + $this->importer->importFile($file->getPathname()); + } + } + } + + private function configureContext(InputInterface $input, OutputInterface $output): void + { + $this->contextRegistry->setDefaultIfEmpty(); + + $contextNames = $this->contextRegistry->getNames(); + $applicationDefinition = $this->application->getDefinition(); + + if ($contextNames) { + $defaultContext = PlatformHelper::getEnv('CASTOR_CONTEXT') ?: $this->contextRegistry->getDefaultName(); + + $applicationDefinition->addOption(new InputOption( + 'context', + '_complete' === $input->getFirstArgument() || 'list' === $input->getFirstArgument() ? null : 'c', + InputOption::VALUE_REQUIRED, + sprintf('The context to use (%s)', implode('|', $contextNames)), + $defaultContext, + $contextNames, + )); + } + + try { + $input->bind($applicationDefinition); + } catch (ExceptionInterface) { + // not an issue if parsing gone wrong, we'll just use the default + // context and it will fail later anyway + } + + // occurs when running `castor -h`, or if no context is defined + if (!$input->hasOption('context')) { + $this->contextRegistry->setCurrentContext(new Context()); + + return; + } + + $context = $this + ->contextRegistry + ->get($input->getOption('context')) + ; + + if ($context->verbosityLevel->isNotConfigured()) { + $context = $context->withVerbosityLevel(VerbosityLevel::fromSymfonyOutput($output)); + } + + $this->contextRegistry->setCurrentContext($context->withName($input->getOption('context'))); + } +} diff --git a/src/Listener/ConfigureCastorListener.php b/src/Listener/ConfigureCastorListener.php index aec6c946..b6e50bd7 100644 --- a/src/Listener/ConfigureCastorListener.php +++ b/src/Listener/ConfigureCastorListener.php @@ -2,7 +2,7 @@ namespace Castor\Listener; -use Castor\Event\BeforeApplicationBootEvent; +use Castor\Event\BeforeBootEvent; use Monolog\Logger; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; @@ -22,7 +22,7 @@ public function __construct( } #[AsEventListener()] - public function configureCastor(BeforeApplicationBootEvent $event): void + public function configureCastor(BeforeBootEvent $event): void { if ($this->logger instanceof Logger) { $this->logger->pushHandler(new ConsoleHandler($this->output)); diff --git a/src/functions-internal.php b/src/functions-internal.php index 2282e3cf..6480f758 100644 --- a/src/functions-internal.php +++ b/src/functions-internal.php @@ -2,11 +2,11 @@ namespace Castor\Internal; -use Castor\Function\FunctionFinder; - /** * Don't leak internal variables when requiring files. * + * Must only be called by Castor\Import\Importer::importFile() + * * @internal */ function castor_require(string $file): void @@ -15,8 +15,6 @@ function castor_require(string $file): void throw new \RuntimeException(sprintf('Could not find file "%s".', $file)); } - FunctionFinder::$files[] = $file; - require_once $file; } diff --git a/src/functions.php b/src/functions.php index 34d30385..bf29a590 100644 --- a/src/functions.php +++ b/src/functions.php @@ -451,7 +451,7 @@ function mount(string $path, ?string $namespacePrefix = null): void throw fix_exception(new \InvalidArgumentException(sprintf('The directory "%s" does not exist.', $path))); } - Container::get()->kernel->addMount(new Mount($path, $namespacePrefix)); + Container::get()->kernel->addMount(new Mount($path, namespacePrefix: $namespacePrefix)); } /** diff --git a/tests/Generated/ContextGeneratorArg2Test.php.err.txt b/tests/Generated/ContextGeneratorArg2Test.php.err.txt index d9151e4f..29e8386d 100644 --- a/tests/Generated/ContextGeneratorArg2Test.php.err.txt +++ b/tests/Generated/ContextGeneratorArg2Test.php.err.txt @@ -1,4 +1,4 @@ -In FunctionFinder.php line XXXX: +In FunctionResolver.php line XXXX: Function "gen()" is not properly configured: The context generator "foo" must not have arguments. diff --git a/tests/Generated/ContextGeneratorArgTest.php.err.txt b/tests/Generated/ContextGeneratorArgTest.php.err.txt index 6b94282c..3a8e6e24 100644 --- a/tests/Generated/ContextGeneratorArgTest.php.err.txt +++ b/tests/Generated/ContextGeneratorArgTest.php.err.txt @@ -1,4 +1,4 @@ -In FunctionFinder.php line XXXX: +In FunctionResolver.php line XXXX: Function "gen()" is not properly configured: The contexts generator must not have arguments. diff --git a/tests/Generated/ContextGeneratorNotCallableTest.php.err.txt b/tests/Generated/ContextGeneratorNotCallableTest.php.err.txt index 879e7466..425b6165 100644 --- a/tests/Generated/ContextGeneratorNotCallableTest.php.err.txt +++ b/tests/Generated/ContextGeneratorNotCallableTest.php.err.txt @@ -1,4 +1,4 @@ -In FunctionFinder.php line XXXX: +In FunctionResolver.php line XXXX: Function "gen()" is not properly configured: The context generator "foo" is not callable.