diff --git a/doc/extensions.md b/doc/extensions.md index f8e620cfd..f054dc45a 100644 --- a/doc/extensions.md +++ b/doc/extensions.md @@ -1,8 +1,9 @@ # Extensions -You will probably have some custom tasks or event listeners that are not included in the default GrumPHP project. +You might have [a custom tasks](tasks.md#creating-a-custom-task) + or [event listeners](runner.md#events) that is not included in the default GrumPHP project. It is possible to group this additional GrumPHP configuration in an extension. -This way you can easily create your own extension package and load it whenever you need it. +This way you can centralize this custom logic in your own extension package and load it wherever you need it. The configuration looks like this: @@ -13,9 +14,15 @@ grumphp: - My\Project\GrumPHPExtension ``` -The configured extension class needs to implement `ExtensionInterface`. -Now you can register the tasks and events from your own package in the service container of GrumPHP. -For example: +The configured extension class needs to implement `GrumPHP\Extension\ExtensionInterface`. +Since GrumPHP is using the [symfony/dependency-injection](https://symfony.com/doc/current/service_container.html) internally to configure all resources, +a GrumPHP extension can append multiple configuration files to the container configuration. + +We support following loaders: YAML, XML, INI, GLOB, DIR. +*Note:* We don't support the PHP or CLOSURE loaders to make sure your extension is compatible with our grumphp-shim PHAR distribution. +All dependencies get scoped with a random prefix in the PHAR, making these loaders not usable in there. + +Example extension: ```php getParameter('extensions'); $extensions = \is_array($extensions) ? $extensions : []; foreach ($extensions as $extensionClass) { @@ -28,7 +30,9 @@ public function process(ContainerBuilder $container): void )); } - $extension->load($container); + foreach ($extension->imports() as $import) { + $loader->load($import); + } } } } diff --git a/src/Configuration/ContainerBuilder.php b/src/Configuration/ContainerBuilder.php index b1bd06daf..9eef2c0b4 100644 --- a/src/Configuration/ContainerBuilder.php +++ b/src/Configuration/ContainerBuilder.php @@ -5,10 +5,8 @@ namespace GrumPHP\Configuration; use GrumPHP\Util\Filesystem; -use Symfony\Component\Config\FileLocator; use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; use Symfony\Component\DependencyInjection\ContainerBuilder as SymfonyContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; final class ContainerBuilder { @@ -30,7 +28,7 @@ public static function buildFromConfiguration(string $path): SymfonyContainerBui // Load basic service file + custom user configuration $configDir = dirname(__DIR__, 2).$filesystem->ensureValidSlashes('/resources/config'); - $loader = new YamlFileLoader($container, new FileLocator($configDir)); + $loader = LoaderFactory::createLoader($container, [$configDir]); $loader->load('config.yml'); $loader->load('console.yml'); $loader->load('fixer.yml'); diff --git a/src/Configuration/Loader/DistFileLoader.php b/src/Configuration/Loader/DistFileLoader.php new file mode 100644 index 000000000..ada07a1f6 --- /dev/null +++ b/src/Configuration/Loader/DistFileLoader.php @@ -0,0 +1,55 @@ +loader = $loader; + } + + public function load(mixed $resource, string $type = null): void + { + $this->loader->load($resource, $type); + } + + public function supports(mixed $resource, string $type = null): bool + { + if (!\is_string($resource)) { + return false; + } + + if ($type !== null) { + return $this->loader->supports($resource, $type); + } + + $extension = pathinfo($resource, \PATHINFO_EXTENSION); + if ($extension !== 'dist') { + return false; + } + + $distForFile = pathinfo($resource, \PATHINFO_FILENAME); + + return $this->loader->supports($distForFile); + } + + public function getResolver(): LoaderResolverInterface + { + return $this->loader->getResolver(); + } + + public function setResolver(LoaderResolverInterface $resolver): void + { + $this->loader->setResolver($resolver); + } +} diff --git a/src/Configuration/LoaderFactory.php b/src/Configuration/LoaderFactory.php new file mode 100644 index 000000000..bc44979ab --- /dev/null +++ b/src/Configuration/LoaderFactory.php @@ -0,0 +1,40 @@ + $paths + */ + public static function createLoader(ContainerBuilder $container, array $paths = []): DelegatingLoader + { + $locator = new FileLocator($paths); + $resolver = new LoaderResolver([ + $xmlLoader = new XmlFileLoader($container, $locator, self::ENV), + $yamlLoader = new YamlFileLoader($container, $locator, self::ENV), + $iniLoader = new IniFileLoader($container, $locator, self::ENV), + new GlobFileLoader($container, $locator, self::ENV), + new DirectoryLoader($container, $locator, self::ENV), + new DistFileLoader($xmlLoader), + new DistFileLoader($yamlLoader), + new DistFileLoader($iniLoader), + ]); + + return new DelegatingLoader($resolver); + } +} diff --git a/src/Extension/ExtensionInterface.php b/src/Extension/ExtensionInterface.php index 2dcb2477d..b85bf7c48 100644 --- a/src/Extension/ExtensionInterface.php +++ b/src/Extension/ExtensionInterface.php @@ -4,13 +4,21 @@ namespace GrumPHP\Extension; -use Symfony\Component\DependencyInjection\ContainerBuilder; - /** - * Interface ExtensionInterface is used for GrumPHP extensions to interface - * with GrumPHP through the service container. + * Registers your own GrumPHP. */ interface ExtensionInterface { - public function load(ContainerBuilder $container): void; + /** + * Return a list of additional symfony/conso:e service imports that + * GrumPHP needs to perform after loading all internal configurations. + * + * We support following loaders: YAML, XML, INI, GLOB, DIR + * + * More info + * @link https://symfony.com/doc/current/service_container.html + * + * @return iterable + */ + public function imports(): iterable; } diff --git a/test/E2E/AbstractE2ETestCase.php b/test/E2E/AbstractE2ETestCase.php index e7b2dcd99..a4b892200 100644 --- a/test/E2E/AbstractE2ETestCase.php +++ b/test/E2E/AbstractE2ETestCase.php @@ -162,7 +162,7 @@ private function detectCurrentGrumphpGitBranchForComposerWithFallback(): string $version = trim($process->getOutput()); } - return 'dev-'.$version; + return 'dev-'.$version.'@dev'; } protected function mergeComposerConfig(string $composerFile, array $config, $recursive = true) @@ -295,6 +295,34 @@ protected function enableValidatePathsTask(string $grumphpFile, string $projectD ]); } + protected function enableCustomExtension(string $grumphpFile, string $projectDir) + { + $e2eDir = $this->ensureGrumphpE2eTasksDir($projectDir); + $this->dumpFile( + $e2eDir.'/SuccessfulTask.php', + file_get_contents(TEST_BASE_PATH.'/fixtures/e2e/tasks/SuccessfulTask.php') + ); + $this->dumpFile( + $e2eDir.'/ValidateExtension.php', + file_get_contents(TEST_BASE_PATH.'/fixtures/e2e/extension/ValidateExtension.php') + ); + $this->dumpFile( + $e2eDir.'/extension.yaml', + file_get_contents(TEST_BASE_PATH.'/fixtures/e2e/extension/extension.yaml') + ); + + $this->mergeGrumphpConfig($grumphpFile, [ + 'grumphp' => [ + 'extensions' => [ + \GrumPHPE2E\ValidateExtension::class, + ], + 'tasks' => [ + 'success' => [], + ], + ], + ]); + } + protected function installComposer(string $path, array $arguments = []) { $process = new Process( diff --git a/test/E2E/ExtensionsTest.php b/test/E2E/ExtensionsTest.php new file mode 100644 index 000000000..baa796a08 --- /dev/null +++ b/test/E2E/ExtensionsTest.php @@ -0,0 +1,23 @@ +initializeGitInRootDir(); + $this->initializeComposer($this->rootDir); + $grumphpFile = $this->initializeGrumphpConfig($this->rootDir); + $this->installComposer($this->rootDir); + $this->ensureHooksExist(); + + $this->enableCustomExtension($grumphpFile, $this->rootDir); + + $this->commitAll(); + $this->runGrumphp($this->rootDir); + } +} diff --git a/test/fixtures/e2e/extension/ValidateExtension.php b/test/fixtures/e2e/extension/ValidateExtension.php new file mode 100644 index 000000000..c81761fa0 --- /dev/null +++ b/test/fixtures/e2e/extension/ValidateExtension.php @@ -0,0 +1,14 @@ +config = new EmptyTaskConfig(); + } + + public function getConfig(): TaskConfigInterface + { + return $this->config; + } + + public function withConfig(TaskConfigInterface $config): TaskInterface + { + $new = clone $this; + $new->config = $config; + + return $new; + } + + public static function getConfigurableOptions(): ConfigOptionsResolver + { + return ConfigOptionsResolver::fromOptionsResolver(new OptionsResolver()); + } + + public function canRunInContext(ContextInterface $context): bool + { + return true; + } + + public function run(ContextInterface $context): TaskResultInterface + { + return TaskResult::createPassed($this, $context); + } +}