From e1edb3b69cabdf4ed3c080c2f8ea263159fcc9d6 Mon Sep 17 00:00:00 2001
From: Demian Katz <demian.katz@villanova.edu>
Date: Tue, 12 May 2020 08:32:17 -0400
Subject: [PATCH] Improve plugin generator. (#1573)

- Add support for generating controllers and controller plugins.
- Add support for generating plugin types from other VuFind modules (e.g. VuFindConsole Commands).
- Add option to create top-level services.
- Improved factory generation (use $requestedName instead of hard-coded class name).
- Better aliases for controllers.
---
 .../Command/Generate/PluginCommand.php        |  12 +-
 .../Generator/GeneratorTools.php              | 214 +++++++++++++++---
 .../Command/Generate/PluginCommandTest.php    |   9 +-
 3 files changed, 204 insertions(+), 31 deletions(-)

diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/PluginCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/PluginCommand.php
index ec5ad658913..dfd4aaa0592 100644
--- a/module/VuFindConsole/src/VuFindConsole/Command/Generate/PluginCommand.php
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/PluginCommand.php
@@ -29,6 +29,7 @@ namespace VuFindConsole\Command\Generate;
 
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 
 /**
@@ -67,6 +68,12 @@ class PluginCommand extends AbstractContainerAwareCommand
                 'factory',
                 InputArgument::OPTIONAL,
                 'an existing factory to use (omit to generate a new one)'
+            )->addOption(
+                'top-level',
+                null,
+                InputOption::VALUE_NONE,
+                'when set, create the plugin as a top-level service instead of'
+                . ' inside a plugin manager'
             );
     }
 
@@ -82,9 +89,10 @@ class PluginCommand extends AbstractContainerAwareCommand
     {
         $class = $input->getArgument('class_name');
         $factory = $input->getArgument('factory');
+        $topLevel = $input->getOption('top-level');
         try {
-            $this->generatorTools->setOutputInterface($output);
-            $this->generatorTools->createPlugin($this->container, $class, $factory);
+            $this->generatorTools->setOutputInterface($output)
+                ->createPlugin($this->container, $class, $factory, $topLevel);
         } catch (\Exception $e) {
             $output->writeln($e->getMessage());
             return 1;
diff --git a/module/VuFindConsole/src/VuFindConsole/Generator/GeneratorTools.php b/module/VuFindConsole/src/VuFindConsole/Generator/GeneratorTools.php
index 0138401b883..ee00a5199fa 100644
--- a/module/VuFindConsole/src/VuFindConsole/Generator/GeneratorTools.php
+++ b/module/VuFindConsole/src/VuFindConsole/Generator/GeneratorTools.php
@@ -63,27 +63,194 @@ class GeneratorTools
         $this->config = $config;
     }
 
+    /**
+     * Determine a plugin manager name within the specified namespace.
+     *
+     * @param array  $classParts Exploded class name array
+     * @param string $namespace  Namespace to try for plugin manager
+     *
+     * @return string
+     */
+    protected function getPluginManagerForNamespace($classParts, $namespace)
+    {
+        $classParts[0] = $namespace;
+        $classParts[count($classParts) - 1] = 'PluginManager';
+        return implode('\\', $classParts);
+    }
+
+    /**
+     * Get a list of VuFind modules (only those with names beginning with VuFind,
+     * and not including the core VuFind module itself).
+     *
+     * @return array
+     */
+    protected function getVuFindExtendedModules()
+    {
+        $moduleDir = __DIR__ . '/../../../../';
+        $handle = opendir($moduleDir);
+        $results = [];
+        while ($line = readdir($handle)) {
+            if (substr($line, 0, 6) === 'VuFind' && strlen($line) > 6) {
+                $results[] = $line;
+            }
+        }
+        closedir($handle);
+        return $results;
+    }
+
+    /**
+     * Given a class name exploded into an array, figure out the appropriate plugin
+     * manager to use.
+     *
+     * @param array $classParts Exploded class name array
+     *
+     * @return string
+     */
+    protected function getPluginManagerFromExplodedClassName($classParts)
+    {
+        $pmClass = $this->getPluginManagerForNamespace($classParts, 'VuFind');
+        // Special cases: no such service; use framework core services instead:
+        if ($pmClass === 'VuFind\Controller\PluginManager') {
+            return 'ControllerManager';
+        }
+        if ($pmClass === 'VuFind\Controller\Plugin\PluginManager') {
+            return \Laminas\Mvc\Controller\PluginManager::class;
+        }
+        // Special case: no such service; check other modules:
+        if (!class_exists($pmClass)) {
+            foreach ($this->getVuFindExtendedModules() as $module) {
+                $pmClass = $this->getPluginManagerForNamespace($classParts, $module);
+                if (class_exists($pmClass)) {
+                    break;
+                }
+            }
+        }
+        return $pmClass;
+    }
+
+    /**
+     * Given a class name exploded into an array, figure out the appropriate short
+     * name to use as an alias in the service manager configuration.
+     *
+     * @param array $classParts Exploded class name array
+     *
+     * @return string
+     */
+    protected function getShortNameFromExplodedClassName($classParts)
+    {
+        $shortName = array_pop($classParts);
+        // Special case: controllers use shortened aliases
+        if (($classParts[1] ?? '') === 'Controller') {
+            return preg_replace('/Controller$/', '', $shortName);
+        }
+        return strtolower($shortName);
+    }
+
+    /**
+     * Given a plugin manager object, return the interface plugins of that type must
+     * implement.
+     *
+     * @param ContainerInterface $pm Plugin manager
+     *
+     * @return string
+     */
+    protected function getExpectedInterfaceFromPluginManager($pm)
+    {
+        // Special case: controllers
+        if ($pm instanceof \Laminas\Mvc\Controller\ControllerManager) {
+            return \VuFind\Controller\AbstractBase::class;
+        }
+
+        // Special case: controller plugins:
+        if ($pm instanceof \Laminas\Mvc\Controller\PluginManager) {
+            return \Laminas\Mvc\Controller\Plugin\AbstractPlugin::class;
+        }
+
+        // Default case: look it up:
+        if (!method_exists($pm, 'getExpectedInterface')) {
+            return null;
+        }
+
+        // Force getExpectedInterface() to be public so we can read it:
+        $reflectionMethod = new \ReflectionMethod($pm, 'getExpectedInterface');
+        $reflectionMethod->setAccessible(true);
+        return $reflectionMethod->invoke($pm);
+    }
+
+    /**
+     * Given a plugin manager class name, return the configuration path for that
+     * plugin manager.
+     *
+     * @param string $class Class name
+     *
+     * @return array
+     */
+    protected function getConfigPathForClass($class)
+    {
+        // Special case: controller
+        if ($class === \Laminas\Mvc\Controller\ControllerManager::class) {
+            return ['controllers'];
+        } elseif ($class == \Laminas\Mvc\Controller\PluginManager::class) {
+            return ['controller_plugins'];
+        } elseif ($class == \Laminas\ServiceManager\ServiceManager::class) {
+            return ['service_manager'];
+        }
+        // Default case: VuFind internal plugin manager
+        $apmFactory = new \VuFind\ServiceManager\AbstractPluginManagerFactory();
+        $pmKey = $apmFactory->getConfigKey($class);
+        return ['vufind', 'plugin_managers', $pmKey];
+    }
+
+    /**
+     * Given appropriate inputs, figure out which plugin manager or service manager
+     * to use during plugin generation.
+     *
+     * @param ContainerInterface $container       Service manager
+     * @param array              $classParts      Exploded class name array
+     * @param bool               $topLevelService Set to true to build a service
+     * in the top-level container rather than a plugin in a subsidiary plugin manager
+     *
+     * @return ContainerInterface
+     */
+    protected function getPluginManagerForClassParts($container, $classParts,
+        $topLevelService
+    ) {
+        // Special case -- short-circuit for top-level service:
+        if ($topLevelService) {
+            return $container;
+        }
+        $pmClass = $this->getPluginManagerFromExplodedClassName($classParts);
+        if (!$container->has($pmClass)) {
+            throw new \Exception(
+                'Cannot find expected plugin manager: ' . $pmClass . "\n"
+                . 'You can use the --top-level option if you wish to create'
+                . ' a top-level service.'
+            );
+        }
+        return $container->get($pmClass);
+    }
+
     /**
      * Create a plugin class.
      *
-     * @param ContainerInterface $container Service manager
-     * @param string             $class     Class name to create
-     * @param string             $factory   Existing factory to use (null to
+     * @param ContainerInterface $container       Service manager
+     * @param string             $class           Class name to create
+     * @param string             $factory         Existing factory to use (null to
      * generate a new one)
+     * @param bool               $topLevelService Set to true to build a service
+     * in the top-level container rather than a plugin in a subsidiary plugin manager
      *
      * @return bool
      * @throws \Exception
      */
     public function createPlugin(ContainerInterface $container, $class,
-        $factory = null
+        $factory = null, $topLevelService = false
     ) {
         // Derive some key bits of information from the new class name:
         $classParts = explode('\\', $class);
         $module = $classParts[0];
-        $shortName = strtolower(array_pop($classParts));
-        $classParts[0] = 'VuFind';
-        $classParts[] = 'PluginManager';
-        $pmClass = implode('\\', $classParts);
+        $shortName = $this->getShortNameFromExplodedClassName($classParts);
+
         // Set a flag for whether to generate a factory, and create class name
         // if necessary. If existing factory specified, ensure it really exists.
         if ($generateFactory = empty($factory)) {
@@ -93,20 +260,10 @@ class GeneratorTools
         }
 
         // Figure out further information based on the plugin manager:
-        if (!$container->has($pmClass)) {
-            throw new \Exception('Cannot find expected plugin manager: ' . $pmClass);
-        }
-        $pm = $container->get($pmClass);
-        if (!method_exists($pm, 'getExpectedInterface')) {
-            throw new \Exception(
-                $pmClass . ' does not implement getExpectedInterface!'
-            );
-        }
-
-        // Force getExpectedInterface() to be public so we can read it:
-        $reflectionMethod = new \ReflectionMethod($pm, 'getExpectedInterface');
-        $reflectionMethod->setAccessible(true);
-        $interface = $reflectionMethod->invoke($pm);
+        $pm = $this->getPluginManagerForClassParts(
+            $container, $classParts, $topLevelService
+        );
+        $interface = $this->getExpectedInterfaceFromPluginManager($pm);
 
         // Figure out whether the plugin requirement is an interface or a
         // parent class so we can create the right thing....
@@ -117,9 +274,7 @@ class GeneratorTools
             $parent = $interface;
             $interfaces = [];
         }
-        $apmFactory = new \VuFind\ServiceManager\AbstractPluginManagerFactory();
-        $pmKey = $apmFactory->getConfigKey(get_class($pm));
-        $configPath = ['vufind', 'plugin_managers', $pmKey];
+        $configPath = $this->getConfigPathForClass(get_class($pm));
 
         // Generate the classes and configuration:
         $this->createClassInModule($class, $module, $parent, $interfaces);
@@ -132,6 +287,13 @@ class GeneratorTools
         // Don't back up the config twice -- the first backup from the previous
         // write operation is sufficient.
         $this->writeNewConfig($aliasPath, $class, $module, false);
+        // Add extra lowercase alias if necessary:
+        if (strtolower($shortName) != $shortName) {
+            $lowerAliasPath = array_merge(
+                $configPath, ['aliases', strtolower($shortName)]
+            );
+            $this->writeNewConfig($lowerAliasPath, $class, $module, false);
+        }
 
         return true;
     }
@@ -154,7 +316,7 @@ class GeneratorTools
                 $method = MethodGenerator::fromArray(
                     [
                         'name' => '__invoke',
-                        'body' => 'return new \\' . $class . '();',
+                        'body' => 'return new $requestedName();',
                     ]
                 );
                 $param1 = [
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/PluginCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/PluginCommandTest.php
index 27f54b4571e..91a50b3ce1a 100644
--- a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/PluginCommandTest.php
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/PluginCommandTest.php
@@ -75,7 +75,8 @@ class PluginCommandTest extends \PHPUnit\Framework\TestCase
         $tools = $this->getMockGeneratorTools(
             ['setOutputInterface', 'createPlugin']
         );
-        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('setOutputInterface')
+            ->will($this->returnValue($tools));
         $tools->expects($this->once())->method('createPlugin')
             ->with(
                 $this->equalTo($container),
@@ -99,7 +100,8 @@ class PluginCommandTest extends \PHPUnit\Framework\TestCase
         $tools = $this->getMockGeneratorTools(
             ['setOutputInterface', 'createPlugin']
         );
-        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('setOutputInterface')
+            ->will($this->returnValue($tools));
         $tools->expects($this->once())->method('createPlugin')
             ->with(
                 $this->equalTo($container),
@@ -125,7 +127,8 @@ class PluginCommandTest extends \PHPUnit\Framework\TestCase
         $tools = $this->getMockGeneratorTools(
             ['createPlugin', 'setOutputInterface']
         );
-        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('setOutputInterface')
+            ->will($this->returnValue($tools));
         $tools->expects($this->once())->method('createPlugin')
             ->will($this->throwException(new \Exception('Foo!')));
         $command = new PluginCommand($tools, $container);
-- 
GitLab