diff --git a/.ddev/web-build/Dockerfile b/.ddev/web-build/Dockerfile index b411fe5..25886c1 100644 --- a/.ddev/web-build/Dockerfile +++ b/.ddev/web-build/Dockerfile @@ -1,6 +1,12 @@ RUN composer global config --no-plugins allow-plugins.ion-bazan/composer-diff true; \ composer global require ion-bazan/composer-diff --no-interaction; #RUN python -m pip install --upgrade pip +# The Python 3.13 base image ships an empty `setuptools` namespace package with no +# RECORD file, so `pip install -U setuptools` fails (uninstall-no-record-file) and +# legacy setup.py builds (e.g. the mkdocs-edit-url git package below) die with +# "module 'setuptools' has no attribute 'setup'". Install a real setuptools + wheel +# into /usr/local (earlier on sys.path) with --ignore-installed so it shadows the stub. +RUN pip install --break-system-packages --ignore-installed "setuptools<81" wheel RUN pip install --break-system-packages \ mkdocs==1.5.3 \ mkdocs-material \ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..018b17a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,111 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`digitalpolygon/polymer` is a **Composer plugin** that ships a Robo-based CLI (`bin/polymer`) for WebOps tooling — primarily artifact building/deployment for PHP/Drupal projects. It is intended to be extended by other packages (e.g. `polymer-drupal`, `polymer-pantheon-drupal`), not used as a standalone application. + +Package autoload (note the `Core` segment): `DigitalPolygon\Polymer\Core\` → `src/`. Tests autoload: `DigitalPolygon\PolymerTest\` → `tests/`. + +## Common commands + +All Composer scripts are defined in `composer.json`: + +| Command | What it does | +| --- | --- | +| `composer cs` | PHP_CodeSniffer against `phpcs.xml.dist` (PSR-12 over `src/` and `bin/`) | +| `composer lint` | Parallel `php -l` syntax check | +| `composer sa` | PHPStan level 6 against `src/` and `bin/` (config: `phpstan.neon`) | +| `composer validations` | Runs `lint`, `cs`, and `sa` in sequence — the CI gate | +| `composer nuke` / `composer nuke-install` | Wipe `vendor/` + lockfile (and reinstall) | + +Per the user's global rules: after modifying PHP, run it through **PHPStan** (`composer sa`) before declaring the change done. + +### Tests + +PHPUnit must be invoked **from `tests/phpunit/`** because `phpunit.xml.dist` references `unit/` relative to that directory: + +```bash +cd tests/phpunit && ../../vendor/bin/phpunit # full suite +cd tests/phpunit && ../../vendor/bin/phpunit --filter testMethodName # single test +cd tests/phpunit && ../../vendor/bin/phpunit-watcher watch # watch mode +``` + +CI matrix runs against PHP 8.1, 8.2, 8.3 (`.github/workflows/code_standards.yml`, `phpunit.yml`). + +### DDEV (development environment) + +Documentation work happens inside DDEV (Python/`mkdocs` toolchain is in the web container): + +- `ddev init` — full reset, `composer install`, build docs, open browser +- `ddev build-docs` — runs `polymer mk:docs` then `mkdocs build --clean`; view at `:444` +- `ddev dev-docs` — live `mkdocs serve` at `:445` (preferred while editing docs) + +When command-class docblocks/attributes change, regenerate the per-command `docs/commands/*.md` with `polymer mk:docs` before building the site. + +## Architecture + +### Bootstrap flow (`bin/polymer` → `Polymer::run()`) + +1. `bin/polymer` → `bin/polymer-robo-run.php` walks up from CWD looking for a `.polymer/` directory; this is the **repo root** (everything is anchored to it). +2. `Polymer::boot()` runs five phases in order: `setupBootContainer` → `createApplication` → `discoverExtensions` → `setupContainer` → `configureRunner`. +3. `RoboRunner::run()` is handed the merged `[commands + hooks]` array; Robo + Symfony Console take over. + +The two-container split (`bootContainer` then full `Container`) exists so extension discovery can run before the main DI container is finalized — extensions contribute service providers that need to register during container setup. + +### Extension model (the core extensibility surface) + +An external Composer package becomes a Polymer extension by: + +1. Having a directory under `/.polymer/plugins//` containing a `.poly_info.yml` marker file, **and** being listed in `/.polymer/config.yml` under `enabled_extensions`. +2. Exposing a `src/` directory; its PSR-4 namespace is **forced** to `DigitalPolygon\Polymer\\` (see `ExtensionDiscovery::registerExtensionNamespaces()` — the class loader is mutated at runtime). +3. Providing an `ExtensionInfo.php` implementing `PolymerExtensionInterface` at the root of that namespace. +4. Optionally providing a service provider next to `ExtensionInfo.php`, named `ServiceProvider`, implementing `League\Container\ServiceProvider\ServiceProviderInterface`. + +Within an extension, commands live in `Plugin\Commands\*Commands.php` and hooks in `Plugin\Hooks\*Hook.php`. Polymer Core's own commands follow the same `*Command.php` / `*Hook.php` suffix convention but are discovered separately via `CoreClassDiscovery` and the `Robo\Commands\` / `Robo\Hooks\` relative namespaces. + +The discovery suffix mismatch is intentional and load-bearing — **don't "normalize" it**: +- Core: files end in `Command.php` / `Hook.php` +- Extensions: files end in `Commands.php` / `Hook.php` + +### Configuration system (`src/Robo/Config/`) + +Config is **not** available during boot — it's compiled lazily on each command invocation. The flow: + +1. `LoadConfiguration` event subscriber fires at command start. +2. It dispatches `CollectConfigContextsEvent`, allowing extensions to contribute named contexts (each a YAML-derived array). +3. Then `AlterConfigContextsEvent`, allowing late mutation. +4. `PolymerConfig` (extends `Robo\Config`) layers contexts; higher contexts override lower keys. `${tokens}` are resolved against the merged result by `YamlConfigProcessor`. +5. The `process` context is always top-of-stack — runtime `$config->set()` writes go there. + +**Important quirk**: `commandInvoker->invokeCommand()` reloads config from scratch for the invoked command, so a `$this->getConfig()->set('foo', 'bar')` before invoking a sub-command will **not** propagate. Use `pinGlobalOption()` for parameter pass-through. See `docs/developing/command_invoker.md`. + +### Commands + +Built using `consolidation/annotated-command` with PHP attributes (`#[Command]`, `#[Argument]`, `#[Option]`, `#[Usage]`). All command classes extend `TaskBase` (`src/Robo/Tasks/TaskBase.php`). + +Commands prefixed `internal:` are hidden from `list` output by default — this is enforced by `CommandInfoAlterer` running *before* Robo's annotation processing, so `#[Help(hidden: false)]` cannot un-hide them (see `config/default.yml` comment). + +Notable namespaces under `src/Robo/Commands/`: +- `Artifact/` — `artifact:compile`, `artifact:deploy`, `artifact:build:prepare`, `artifact:build:sanitize`, `artifact:composer:install` (the deployment pipeline) +- `Source/` — `source:build:copy`, `source:build:frontend` +- `Docs/` — `mk:docs` (regenerates per-command markdown for the docs site) +- `Template/` — template generation via `TemplatePluginManager` +- `Validate/`, `Polymer/` + +### Composer plugin (`src/Composer/Plugin.php`) + +Implements `PluginInterface` + `EventSubscriberInterface`. On `post-install` / `post-update`, if `digitalpolygon/polymer` itself was just installed/updated **and** `/polymer/polymer.yml` does not yet exist, it shells out to `vendor/bin/polymer polymer:init` to scaffold project files. This is the only thing the Composer plugin does at runtime. + +## Conventions and gotchas + +- **Namespace prefix is `DigitalPolygon\Polymer\Core\` in this package** (the `Core` segment is easy to miss when copying code from extensions, which use `DigitalPolygon\Polymer\\`). +- `test_package/` and `test_drupal10/` are fixture projects used to exercise the Composer plugin / commands end-to-end; they are excluded from PHPStan and PHPCS. Don't lint or analyze them. +- `src/Robo/Common/ArrayManipulator.php` is excluded from PHPStan — it's vendored utility code. +- Default branch for PRs is `0.x`, not `main` (see CI workflow triggers and `mkdocs.yml: edit_uri`). +- GrumPHP runs `phpcs`, `phpstan`, and `phplint` on commit via `phpro/grumphp-shim` — same checks as `composer validations`. + +## Issue tracking + +Polymer development work is tracked in the **PWT** Jira project: https://digitalpolygon.atlassian.net/jira/software/projects/PWT/boards/96 — use the `acli` CLI (ADF format) for Jira interactions. The `composer.json` `support.issues` link points to GitHub issues, but PWT is the active board. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dc366f8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See @AGENTS.md. diff --git a/bin/polymer b/bin/polymer index e9fdc3e..4bd1fca 100755 --- a/bin/polymer +++ b/bin/polymer @@ -1,4 +1,4 @@ #!/usr/bin/env php start(); @@ -24,12 +49,29 @@ } // Initialize configuration. -/** @var string $repoRoot */ -$repoRoot = find_repo_root(); +/** @var string|null $repoRoot */ +$repoRoot = null; +// Walk up the directory tree until we find .polymer or reach the filesystem root. +while (true) { + if (file_exists($cwd . '/.polymer')) { + $repoRoot = $cwd; + break; + } + $parent = dirname($cwd); + if ($parent === $cwd) { + // We have reached the root of the filesystem. + break; + } + $cwd = $parent; +} + +if (!$repoRoot) { + throw new \Exception("Could not find .polymer directory in this or any parent directory. cwd is $cwd; __DIR__ is " . __DIR__); +} // Execute command. -// @phpstan-ignore variable.undefined $polymer = new Polymer($repoRoot, $input, $output, $classLoader); +$polymer->boot(); $status_code = (int) $polymer->run($input, $output); // Stop timer. diff --git a/composer.json b/composer.json index 9e1e0c6..7602335 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,8 @@ }, "require": { "php": ">=8.1", + "oomphinc/composer-installers-extender": "*", + "mnsami/composer-custom-directory-installer": "*", "composer-plugin-api": "^2.0", "composer-runtime-api": "^2.0", "consolidation/robo": "^4 || ^5", @@ -22,19 +24,22 @@ "prefer-stable": true, "autoload": { "psr-4": { - "DigitalPolygon\\Polymer\\": "src/", + "DigitalPolygon\\Polymer\\Core\\": "src/", "DigitalPolygon\\PolymerTest\\": "tests/" } }, "config": { "sort-packages": true, "allow-plugins": { - "phpstan/extension-installer": true, - "phpro/grumphp-shim": true + "composer/installers": true, + "mnsami/composer-custom-directory-installer": true, + "oomphinc/composer-installers-extender": true, + "phpro/grumphp-shim": true, + "phpstan/extension-installer": true } }, "extra": { - "class": "DigitalPolygon\\Polymer\\Composer\\Plugin" + "class": "DigitalPolygon\\Polymer\\Core\\Composer\\Plugin" }, "bin": [ "bin/polymer" diff --git a/config/deploy-exclude.txt b/config/deploy-exclude.txt index f2f8423..c53bbf9 100644 --- a/config/deploy-exclude.txt +++ b/config/deploy-exclude.txt @@ -40,3 +40,6 @@ local.site.yml node_modules /vendor local.polymer.yml +/scratch +/.polymer/core +/.polymer/plugins/contrib diff --git a/src/Composer/Plugin.php b/src/Composer/Plugin.php index 8e70ba9..394283c 100644 --- a/src/Composer/Plugin.php +++ b/src/Composer/Plugin.php @@ -1,6 +1,6 @@ getExtensionDiscovery(); + $installed = $discovery->getInstalledExtensions(); + $enabled = $discovery->getEnabledExtensionNames(); + + $rows = []; + foreach ($installed as $name => $path) { + $rows[$name] = [ + $name, + in_array($name, $enabled, true) ? 'Enabled' : 'Disabled', + $path, + ]; + } + + // Surface plugins enabled in configuration but not installed on disk so + // the misconfiguration is visible rather than silently ignored. + foreach ($enabled as $name) { + if (!isset($installed[$name])) { + $rows[$name] = [$name, 'Enabled (not installed)', '-']; + } + } + + if (empty($rows)) { + $this->say('No Polymer plugins are installed.'); + return; + } + + ksort($rows); + $this->io()->table(['Plugin', 'Status', 'Path'], array_values($rows)); + } + + /** + * Enable a Polymer plugin. + * + * @throws PolymerException + */ + #[Command(name: 'plugin:enable')] + #[Argument(name: 'name', description: 'The plugin machine name, e.g. polymer_drupal.')] + #[Usage(name: 'polymer plugin:enable polymer_drupal', description: 'Enable the polymer_drupal plugin.')] + public function enablePlugin(string $name): void + { + $discovery = $this->getExtensionDiscovery(); + + if (!isset($discovery->getInstalledExtensions()[$name])) { + throw new PolymerException("Plugin '$name' is not installed. Run 'plugin:list' to see installed plugins."); + } + + if ($discovery->enableExtension($name)) { + $this->say("Enabled plugin '$name'."); + } else { + $this->say("Plugin '$name' is already enabled."); + } + } + + /** + * Disable a Polymer plugin. + * + * @throws PolymerException + */ + #[Command(name: 'plugin:disable')] + #[Argument(name: 'name', description: 'The plugin machine name, e.g. polymer_drupal.')] + #[Usage(name: 'polymer plugin:disable polymer_drupal', description: 'Disable the polymer_drupal plugin.')] + public function disablePlugin(string $name): void + { + $discovery = $this->getExtensionDiscovery(); + + if ($discovery->disableExtension($name)) { + $this->say("Disabled plugin '$name'."); + } else { + $this->say("Plugin '$name' is not enabled."); + } + } + + /** + * Resolve the extension discovery service from the container. + * + * @throws PolymerException + */ + protected function getExtensionDiscovery(): ExtensionDiscovery + { + $container = $this->getContainer(); + if (!$container->has('extensionDiscovery')) { + throw new PolymerException('The extension discovery service is not available.'); + } + $discovery = $container->get('extensionDiscovery'); + if (!$discovery instanceof ExtensionDiscovery) { + throw new PolymerException('Unexpected extension discovery service.'); + } + return $discovery; + } +} diff --git a/src/Robo/Commands/Source/BuildCopyCommand.php b/src/Robo/Commands/Source/BuildCopyCommand.php index 64cc65e..5bfdbef 100644 --- a/src/Robo/Commands/Source/BuildCopyCommand.php +++ b/src/Robo/Commands/Source/BuildCopyCommand.php @@ -1,10 +1,10 @@ classLoader->getPrefixesPsr4(); + + if (isset($prefixes[$namespacePrefix])) { + $relativeNamespacePath = $this->convertRelativeNamespaceToPath($relativeNamespace); + $candidateDirectories = array_map(function ($directory) use ($relativeNamespacePath) { + return realpath($directory . $relativeNamespacePath); + }, $prefixes[$namespacePrefix]); + $candidateDirectories = array_filter($candidateDirectories); + if (!empty($candidateDirectories)) { + foreach ($this->search($candidateDirectories, $pattern) as $file) { + $relativePath = DIRECTORY_SEPARATOR . $file->getRelativePathname(); + $relativePathNamespace = $this->convertPathToRelativeNamespace($relativePath); + $class = $namespacePrefix . $relativeNamespace . '\\' . $relativePathNamespace; + $classes[$file->getPathname()] = $class; + } + } + } + + return $classes; + } + + protected function convertRelativeNamespaceToPath(string $relativeNamespace): string + { + return DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, trim($relativeNamespace, '\\')); + } + + protected function convertPathToRelativeNamespace(string $path): string + { + $path = trim($path, DIRECTORY_SEPARATOR); + // Strip only a trailing .php extension; str_replace would also corrupt + // any directory name that happens to contain ".php" mid-string. + if (str_ends_with($path, '.php')) { + $path = substr($path, 0, -4); + } + return str_replace(DIRECTORY_SEPARATOR, '\\', $path); + } + + protected static function search(array $directories, string $pattern): Finder + { + $finder = new Finder(); + $finder->files() + ->name($pattern) + ->in($directories); + + return $finder; + } + + /** + * {@inheritdoc} + */ + public function getFile($class) + { + return $this->classLoader->findFile($class); + } +} diff --git a/src/Robo/Discovery/CommandsDiscovery.php b/src/Robo/Discovery/CommandsDiscovery.php index dab1053..043c1cb 100644 --- a/src/Robo/Discovery/CommandsDiscovery.php +++ b/src/Robo/Discovery/CommandsDiscovery.php @@ -1,6 +1,6 @@ relativeNamespace = trim($relativeNamespace, '\\'); + return $this; + } + + public function getClasses() + { + return $this->getNamespaceClasses(self::CORE_NAMESPACE_PREFIX, $this->relativeNamespace, $this->searchPattern); + } +} diff --git a/src/Robo/Discovery/DiscoveryBase.php b/src/Robo/Discovery/DiscoveryBase.php index 7592057..7dc785a 100644 --- a/src/Robo/Discovery/DiscoveryBase.php +++ b/src/Robo/Discovery/DiscoveryBase.php @@ -1,6 +1,6 @@ classLoader = $classLoader; + } + + /** + * {@inheritdoc} + */ + public function getClasses() + { + $classes = []; + foreach ($this->extensionNamespaceInfo as $namespaceInfo) { + $classes = array_merge($classes, $this->getNamespaceClasses($namespaceInfo['namespace'] . '\\', $this->relativeNamespace, $this->searchPattern)); + } + return $classes; + } +} diff --git a/src/Robo/Discovery/ExtensionDiscovery.php b/src/Robo/Discovery/ExtensionDiscovery.php index 0831c05..d09aa7d 100644 --- a/src/Robo/Discovery/ExtensionDiscovery.php +++ b/src/Robo/Discovery/ExtensionDiscovery.php @@ -1,27 +1,201 @@ + * Extension id keyed to its installed directory. + */ + public function getInstalledExtensions(): array + { + return $this->findExtensions(); + } + + /** + * Determine whether an extension is enabled in configuration. + */ + public function isExtensionEnabled(string $extensionName): bool + { + return in_array($extensionName, $this->getEnabledExtensionNames(), true); + } + + /** + * Enable an extension by adding it to enabled_extensions. + * + * @return bool + * TRUE if configuration changed, FALSE if it was already enabled. + */ + public function enableExtension(string $extensionName): bool + { + $enabled = $this->getEnabledExtensionNames(); + if (in_array($extensionName, $enabled, true)) { + return false; + } + $enabled[] = $extensionName; + $this->writeEnabledExtensions($enabled); + return true; + } + + /** + * Disable an extension by removing it from enabled_extensions. + * + * @return bool + * TRUE if configuration changed, FALSE if it was not enabled. + */ + public function disableExtension(string $extensionName): bool + { + $enabled = $this->getEnabledExtensionNames(); + $key = array_search($extensionName, $enabled, true); + if ($key === false) { + return false; + } + unset($enabled[$key]); + $this->writeEnabledExtensions(array_values($enabled)); + return true; + } + + /** + * Persist the enabled_extensions list back to config.yml. + * + * @param array $enabled + */ + protected function writeEnabledExtensions(array $enabled): void + { + $configFile = $this->polymerFilesRoot . '/config.yml'; + $config = []; + if (file_exists($configFile)) { + $config = Yaml::parseFile($configFile) ?: []; + } + $config['enabled_extensions'] = array_values($enabled); + if (file_put_contents($configFile, Yaml::dump($config, 4, 2)) === false) { + throw new \RuntimeException("Failed to write Polymer configuration to $configFile"); + } + } + + protected function findExtensions(): array + { + $extensions = []; + $pluginDirectories = [ + $this->polymerFilesRoot . '/plugins', + ]; + + // A plugin's .poly_info.yml marker lives at the plugin root, one or two + // levels below plugins/ (e.g. plugins// or plugins/contrib//). + // Globbing at these fixed depths discovers plugins whether Composer + // installed them as symlinks (path repositories) or as real directories + // (dist/committed), while never descending into a plugin's own vendor/ + // tree — which would otherwise surface vendored copies of other plugins. + $markerPatterns = [ + '/*/*.poly_info.yml', + '/*/*/*.poly_info.yml', + ]; + foreach ($pluginDirectories as $pluginDirectory) { + if (!is_dir($pluginDirectory)) { + continue; + } + foreach ($markerPatterns as $pattern) { + foreach (glob($pluginDirectory . $pattern) ?: [] as $markerFile) { + $extensionName = str_replace('.poly_info.yml', '', basename($markerFile)); + $extensions[$extensionName] = dirname($markerFile); + } + } + } + + return $extensions; + } + + /** + * Get the names of extensions enabled in configuration. + * + * This reflects the raw `enabled_extensions` list and does not guarantee + * that the named extensions are actually installed on disk. + * + * @return array + */ + public function getEnabledExtensionNames(): array { - $this->extensionInfoDiscovery = new RelativeNamespaceDiscovery($classLoader); - $this->extensionHookDiscovery = new RelativeNamespaceDiscovery($classLoader); + $configFile = $this->polymerFilesRoot . '/config.yml'; + if (file_exists($configFile)) { + $config = Yaml::parseFile($configFile); + if (isset($config['enabled_extensions']) && is_array($config['enabled_extensions'])) { + return array_values($config['enabled_extensions']); + } + } + + return []; + } - $this->extensionInfoDiscovery->setRelativeNamespace('Polymer'); - $this->extensionInfoDiscovery->setSearchPattern('ExtensionInfo.php'); + /** + * Get enabled extensions that are installed on disk. + * + * Extensions enabled in configuration but not installed are dropped, so + * the result only ever contains extensions that can actually be loaded. + * + * @return array + * Extension id keyed to its installed directory. + */ + protected function getEnabledExtensions(): array + { + $enabledNames = $this->getEnabledExtensionNames(); + if (empty($enabledNames)) { + return []; + } - $this->extensionHookDiscovery->setRelativeNamespace('Polymer\Plugin\Hooks'); - $this->extensionHookDiscovery->setSearchPattern('*Hook.php'); + $installed = $this->findExtensions(); + $enabledExtensions = []; + foreach ($enabledNames as $extensionName) { + if (isset($installed[$extensionName])) { + $enabledExtensions[$extensionName] = $installed[$extensionName]; + } + } + + return $enabledExtensions; + } + + public function getExtensionNamespaceInfo(): array + { + $extensionNamespaces = []; + $extensions = $this->getEnabledExtensions(); + $namespacePrefix = 'DigitalPolygon\\Polymer\\'; + foreach ($extensions as $extensionId => $extensionDir) { + $namespace = $namespacePrefix . $extensionId; + $srcDir = $extensionDir . '/src'; + if (is_dir($srcDir)) { + $extensionNamespaces[$extensionId] = [ + 'namespace' => $namespace, + 'path' => $srcDir, + ]; + } + } + return $extensionNamespaces; + } + + public function registerExtensionNamespaces(): void + { + $namespaces = $this->getExtensionNamespaceInfo(); + foreach ($namespaces as $extensionId => $namespaceInfo) { + $this->classLoader->addPsr4($namespaceInfo['namespace'] . '\\', $namespaceInfo['path']); + } } /** @@ -30,25 +204,31 @@ public function __construct(ClassLoader $classLoader) public function getExtensions(): array { $extensions = []; - $classes = $this->extensionInfoDiscovery->getClasses(); + $namespaceInfos = $this->getExtensionNamespaceInfo(); + foreach ($namespaceInfos as $extensionId => $namespaceInfo) { + $namespaceInfos[$extensionId]['extension_class'] = $namespaceInfo['namespace'] . '\\' . 'ExtensionInfo'; + } - foreach ($classes as $class) { - $extensionReflection = new \ReflectionClass($class); + foreach ($namespaceInfos as $extensionId => $namespaceInfo) { + if (!class_exists($namespaceInfo['extension_class'])) { + // An enabled extension that ships no ExtensionInfo class is skipped + // rather than crashing the CLI with a ReflectionException. + continue; + } + $extensionReflection = new \ReflectionClass($namespaceInfo['extension_class']); if ($extensionReflection->implementsInterface(PolymerExtensionInterface::class)) { /** @var PolymerExtensionInterface $extensionInstance */ $extensionInstance = $extensionReflection->newInstanceWithoutConstructor(); - $extensionName = $extensionInstance->getExtensionName(); $extensionFile = $extensionReflection->getFileName(); $serviceProvider = $extensionInstance->getInstantiatedServiceProvider(); $configFile = $extensionInstance->getDefaultConfigFile(); - $extensionRoot = dirname($extensionFile, 3); + $extensionRoot = realpath($namespaceInfo['path'] . '/../'); if (!$configFile) { // Since extensions live in the relative Polymer namespace, and // developers typically provide a src directory in the root of // their extension, we assume that 3 levels back from the // extension definition is the root of the extension. - $extensionRoot = realpath(dirname($extensionFile, 3)); $defaultConfigFile = $extensionRoot . '/config/default.yml'; if (file_exists($defaultConfigFile)) { $configFile = $defaultConfigFile; @@ -56,15 +236,15 @@ public function getExtensions(): array } if (!$serviceProvider) { // Service providers always live in the same namespace as the extension definition. - $serviceProviderClassName = static::camelCase($extensionName) . 'ServiceProvider'; + $serviceProviderClassName = static::camelCase($extensionId) . 'ServiceProvider'; $serviceProviderClass = $extensionReflection->getNamespaceName() . '\\' . $serviceProviderClassName; if (class_exists($serviceProviderClass)) { $serviceProvider = new $serviceProviderClass(); } } - $extensions[$extensionName] = new ExtensionData( + $extensions[$extensionId] = new ExtensionData( $extensionInstance, - $class, + $extensionInstance::class, $extensionFile, $extensionRoot, $configFile, @@ -77,11 +257,34 @@ public function getExtensions(): array } /** + * Get hook classes for all enabled extensions. + * * @return array */ public function getExtensionHooks(): array { - return $this->extensionHookDiscovery->getClasses(); + $extensionClassDiscovery = new ExtensionClassDiscovery( + $this->classLoader, + $this->getExtensionNamespaceInfo(), + 'Plugin\\Hooks' + ); + $extensionClassDiscovery->setSearchPattern('*Hook.php'); + return $extensionClassDiscovery->getClasses(); + } + + /** + * Get command classes for all enabled extensions. + * @return array + */ + public function getExtensionCommands(): array + { + $extensionClassDiscovery = new ExtensionClassDiscovery( + $this->classLoader, + $this->getExtensionNamespaceInfo(), + 'Plugin\\Commands' + ); + $extensionClassDiscovery->setSearchPattern('*Commands.php'); + return $extensionClassDiscovery->getClasses(); } /** diff --git a/src/Robo/Discovery/Plugin/PluginBase.php b/src/Robo/Discovery/Plugin/PluginBase.php index 2dc180e..93c8edf 100644 --- a/src/Robo/Discovery/Plugin/PluginBase.php +++ b/src/Robo/Discovery/Plugin/PluginBase.php @@ -1,8 +1,8 @@ configureContainer(); - $this->discovery = new RelativeNamespaceDiscovery($this->classLoader); - $this->discovery->setRelativeNamespace($this->relativeNamespace); + $this->discovery = new PluginClassDiscovery( + $this->getContainer()->get('classLoader'), + $this->getContainer()->get('extensionDiscovery')->getExtensionNamespaceInfo(), + $this->relativeNamespace + ); $classes = $this->discovery->getClasses(); $pluginClasses = array_filter($classes, fn ($class) => is_subclass_of($class, $this->pluginInterface)); diff --git a/src/Robo/Discovery/Plugin/PluginManagerInterface.php b/src/Robo/Discovery/Plugin/PluginManagerInterface.php index 2e8c72d..d7ee868 100644 --- a/src/Robo/Discovery/Plugin/PluginManagerInterface.php +++ b/src/Robo/Discovery/Plugin/PluginManagerInterface.php @@ -1,6 +1,6 @@ classLoader = $classLoader; + $this->pluginRelativeNamespace = 'Plugin\\' . trim($relativeNamespace, '\\'); + } + + /** + * {@inheritdoc} + */ + public function getClasses(): array + { + $classes = []; + $prefixes = [ + 'DigitalPolygon\\Polymer\\Core\\', + ]; + foreach ($this->extensionNamespaceInfo as $namespaceInfo) { + $prefixes[] = $namespaceInfo['namespace'] . '\\'; + } + foreach ($prefixes as $prefix) { + $classes = array_merge($classes, $this->getNamespaceClasses($prefix, $this->pluginRelativeNamespace, $this->searchPattern)); + } + return $classes; + } +} diff --git a/src/Robo/Discovery/PushRecipesDiscovery.php b/src/Robo/Discovery/PushRecipesDiscovery.php index a561c16..0ea023a 100644 --- a/src/Robo/Discovery/PushRecipesDiscovery.php +++ b/src/Robo/Discovery/PushRecipesDiscovery.php @@ -1,6 +1,6 @@ */ protected array $extensions; + /** + * @var string The root directory where Polymer files are stored (core, extensions, boot configuration). + */ + protected string $polymerFilesRoot; + /** * Object constructor. * @@ -97,6 +104,11 @@ public function __construct( protected OutputInterface $output, protected ClassLoader $classLoader ) { + $this->polymerFilesRoot = $this->repoRoot . '/.polymer'; + } + + public function boot(): void + { $this ->setupBootContainer() ->createApplication() @@ -127,10 +139,10 @@ protected function discoverExtensions(): self { /** @var ExtensionDiscovery $extensionDiscovery */ $extensionDiscovery = $this->bootContainer->get('extensionDiscovery'); + $extensionDiscovery->registerExtensionNamespaces(); $this->extensions = $extensionDiscovery->getExtensions(); - $this->hooks = $extensionDiscovery->getExtensionHooks(); - $commandsDiscovery = new CommandsDiscovery(); - $this->commands = $commandsDiscovery->getDefinitions(); + $this->hooks = array_merge($this->getCoreHooks(), $extensionDiscovery->getExtensionHooks()); + $this->commands = array_merge($this->getCoreCommands(), $extensionDiscovery->getExtensionCommands()); return $this; } @@ -139,7 +151,9 @@ protected function setupBootContainer(): self { $this->bootContainer = new Container(); $this->bootContainer->addShared('extensionDiscovery', ExtensionDiscovery::class) - ->addArgument($this->classLoader); + ->addArgument($this->classLoader) + ->addArgument($this->repoRoot) + ->addArgument($this->polymerFilesRoot); return $this; } @@ -240,7 +254,6 @@ protected function configureRunner(): self $this->runner = new RoboRunner(); $this->runner->setClassLoader($this->classLoader); $this->runner->setContainer($this->getContainer()); - $this->runner->setRelativePluginNamespace('Polymer\Plugin'); $this->runner->setSelfUpdateRepository(self::REPOSITORY); return $this; @@ -265,8 +278,6 @@ public function run(InputInterface $input, OutputInterface $output): int // Compile the configuration. /** @var \Robo\Application $application */ $application = $this->getContainer()->get('application'); - /** @var ExtensionDiscovery $extensionDiscovery */ - $extensionDiscovery = $this->getContainer()->get('extensionDiscovery'); $mergedCommandsAndHooks = array_merge($this->commands, $this->hooks); return $this->runner->run($input, $output, $application, $mergedCommandsAndHooks); } @@ -282,4 +293,20 @@ protected function collectServiceProviders(): array } return array_filter($serviceProviders); } + + protected function getCoreHooks(): array + { + $coreClassDiscovery = new CoreClassDiscovery($this->classLoader, $this->polymerFilesRoot); + $coreClassDiscovery->setSearchPattern('*Hook.php'); + $coreClassDiscovery->setRelativeNamespace('Robo\\Hooks'); + return $coreClassDiscovery->getClasses(); + } + + protected function getCoreCommands(): array + { + $coreClassDiscovery = new CoreClassDiscovery($this->classLoader, $this->polymerFilesRoot); + $coreClassDiscovery->setSearchPattern('*Command.php'); + $coreClassDiscovery->setRelativeNamespace('Robo\\Commands'); + return $coreClassDiscovery->getClasses(); + } } diff --git a/src/Robo/Services/ClassLoaderAwareTrait.php b/src/Robo/Services/ClassLoaderAwareTrait.php index b250e02..53552da 100644 --- a/src/Robo/Services/ClassLoaderAwareTrait.php +++ b/src/Robo/Services/ClassLoaderAwareTrait.php @@ -1,6 +1,6 @@ relativeNamespace = 'Polymer/Plugin/Template'; + $this->relativeNamespace = 'Template'; $this->pluginInterface = TemplateInterface::class; } diff --git a/src/Robo/Template/Token.php b/src/Robo/Template/Token.php index 388e660..5bc8d79 100644 --- a/src/Robo/Template/Token.php +++ b/src/Robo/Template/Token.php @@ -1,6 +1,6 @@ repoRoot = sys_get_temp_dir() . '/polymer-ext-' . uniqid(); + $this->polymerRoot = $this->repoRoot . '/.polymer'; + + // Plugins installed both as symlinks (path repositories) and as a real + // directory (dist/committed), so discovery is covered for both layouts. + $this->installPlugin('foo'); + $this->installPlugin('bar'); + $this->installPlugin('baz', false); + + // Enable foo plus a "ghost" that is referenced but not installed. + $this->writeConfig(['foo', 'ghost']); + } + + protected function tearDown(): void + { + $this->deleteRecursive($this->repoRoot); + parent::tearDown(); + } + + protected function discovery(): ExtensionDiscovery + { + return new ExtensionDiscovery(new ClassLoader(), $this->repoRoot, $this->polymerRoot); + } + + protected function installPlugin(string $name, bool $asSymlink = true): void + { + if (!$asSymlink) { + // Real directory under plugins/custom (e.g. dist install or a + // committed plugin), not a symlink. + $dir = $this->polymerRoot . '/plugins/custom/' . $name; + mkdir($dir . '/src', 0777, true); + file_put_contents($dir . '/' . $name . '.poly_info.yml', "name: $name\n"); + return; + } + + // Mirror the path-repository layout: the plugin source lives elsewhere + // and is symlinked under .polymer/plugins/contrib. + $source = $this->repoRoot . '/sources/' . $name; + mkdir($source . '/src', 0777, true); + file_put_contents($source . '/' . $name . '.poly_info.yml', "name: $name\n"); + + $contrib = $this->polymerRoot . '/plugins/contrib'; + if (!is_dir($contrib)) { + mkdir($contrib, 0777, true); + } + symlink($source, $contrib . '/' . $name); + } + + /** + * @param array $enabled + */ + protected function writeConfig(array $enabled): void + { + file_put_contents($this->polymerRoot . '/config.yml', Yaml::dump(['enabled_extensions' => $enabled], 4, 2)); + } + + protected function deleteRecursive(string $path): void + { + // Unlink symlinks directly rather than descending into their targets. + if (is_link($path)) { + unlink($path); + return; + } + if (!file_exists($path)) { + return; + } + if (is_file($path)) { + unlink($path); + return; + } + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $this->deleteRecursive($path . '/' . $entry); + } + rmdir($path); + } + + public function testInstalledExtensionsReflectDiskRegardlessOfEnabledState(): void + { + $installed = $this->discovery()->getInstalledExtensions(); + // Symlinked (foo, bar) and real-directory (baz) plugins are all found. + $this->assertEqualsCanonicalizing(['foo', 'bar', 'baz'], array_keys($installed)); + } + + public function testRealDirectoryPluginCanBeEnabledAndRegistered(): void + { + $discovery = $this->discovery(); + $this->assertArrayHasKey('baz', $discovery->getInstalledExtensions()); + // Installed but disabled: not registerable yet. + $this->assertArrayNotHasKey('baz', $discovery->getExtensionNamespaceInfo()); + + $discovery->enableExtension('baz'); + $this->assertArrayHasKey('baz', $discovery->getExtensionNamespaceInfo()); + } + + public function testEnabledNamesReflectRawConfig(): void + { + // Includes the not-installed "ghost" since this is the raw config list. + $this->assertEqualsCanonicalizing(['foo', 'ghost'], $this->discovery()->getEnabledExtensionNames()); + } + + public function testOnlyEnabledInstalledExtensionsAreRegisterable(): void + { + // getExtensionNamespaceInfo() is the basis for namespace, command, and + // hook registration. Disabled (bar) and enabled-but-missing (ghost) + // must both be absent. + $namespaces = $this->discovery()->getExtensionNamespaceInfo(); + $this->assertSame(['foo'], array_keys($namespaces)); + $this->assertSame('DigitalPolygon\\Polymer\\foo', $namespaces['foo']['namespace']); + } + + public function testIsExtensionEnabled(): void + { + $discovery = $this->discovery(); + $this->assertTrue($discovery->isExtensionEnabled('foo')); + $this->assertFalse($discovery->isExtensionEnabled('bar')); + } + + public function testEnableExtensionIsIdempotentAndPersists(): void + { + $discovery = $this->discovery(); + + $this->assertTrue($discovery->enableExtension('bar'), 'Enabling a disabled plugin reports a change.'); + $this->assertFalse($discovery->enableExtension('bar'), 'Enabling an already-enabled plugin reports no change.'); + + // Persisted to config.yml and now registerable. + $config = Yaml::parseFile($this->polymerRoot . '/config.yml'); + $this->assertContains('bar', $config['enabled_extensions']); + $this->assertEqualsCanonicalizing(['foo', 'bar'], array_keys($discovery->getExtensionNamespaceInfo())); + } + + public function testDisableExtensionRemovesItFromRegistration(): void + { + $discovery = $this->discovery(); + + $this->assertTrue($discovery->disableExtension('foo')); + $this->assertFalse($discovery->disableExtension('foo'), 'Disabling a non-enabled plugin reports no change.'); + + $this->assertFalse($discovery->isExtensionEnabled('foo')); + $this->assertSame([], array_keys($discovery->getExtensionNamespaceInfo())); + } +}