Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 316
0.00% covered (danger)
0.00%
0 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
GeneratorTools
0.00% covered (danger)
0.00%
0 / 316
0.00% covered (danger)
0.00%
0 / 31
10920
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPluginManagerForNamespace
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getVuFindExtendedModules
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getPluginManagerFromExplodedClassName
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 getShortNameFromExplodedClassName
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getExpectedInterfaceFromPluginManager
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getConfigPathForClass
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getPluginManagerForClassParts
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 createPlugin
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
42
 generateFactory
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 extendClass
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
110
 getAllFactoriesFromContainer
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getFactoryFromContainer
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAllDelegatorsFromContainer
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getDelegatorsFromContainer
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getPluginManagerContainingClass
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 extendService
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 cloneFactory
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
132
 updateFactory
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 generateLocalClassName
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 createClassInModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 writeClass
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 createSubclassInModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 createTree
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 backUpFile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getModuleConfigPath
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 writeModuleConfig
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 applySettingToConfig
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 writeNewConfigs
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 writeNewConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 retrieveConfig
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3/**
4 * Generator tools.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2018.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Generator
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development Wiki
28 */
29
30namespace VuFindConsole\Generator;
31
32use Laminas\Code\Generator\ClassGenerator;
33use Laminas\Code\Generator\FileGenerator;
34use Laminas\Code\Generator\MethodGenerator;
35use Laminas\Code\Reflection\ClassReflection;
36use Psr\Container\ContainerInterface;
37
38use function count;
39use function in_array;
40use function is_array;
41use function is_callable;
42use function is_string;
43use function strlen;
44
45/**
46 * Generator tools.
47 *
48 * @category VuFind
49 * @package  Generator
50 * @author   Demian Katz <demian.katz@villanova.edu>
51 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
52 * @link     https://vufind.org/wiki/development Wiki
53 */
54class GeneratorTools
55{
56    use \VuFindConsole\ConsoleOutputTrait;
57
58    /**
59     * Laminas configuration
60     *
61     * @var array
62     */
63    protected $config;
64
65    /**
66     * Constructor.
67     *
68     * @param array $config Laminas configuration
69     */
70    public function __construct(array $config)
71    {
72        $this->config = $config;
73    }
74
75    /**
76     * Determine a plugin manager name within the specified namespace.
77     *
78     * @param array  $classParts Exploded class name array
79     * @param string $namespace  Namespace to try for plugin manager
80     *
81     * @return string
82     */
83    protected function getPluginManagerForNamespace($classParts, $namespace)
84    {
85        $classParts[0] = $namespace;
86        $classParts[count($classParts) - 1] = 'PluginManager';
87        return implode('\\', $classParts);
88    }
89
90    /**
91     * Get a list of VuFind modules (only those with names beginning with VuFind,
92     * and not including the core VuFind module itself).
93     *
94     * @return array
95     */
96    protected function getVuFindExtendedModules()
97    {
98        $moduleDir = __DIR__ . '/../../../../';
99        $handle = opendir($moduleDir);
100        $results = [];
101        while ($line = readdir($handle)) {
102            if (str_starts_with($line, 'VuFind') && strlen($line) > 6) {
103                $results[] = $line;
104            }
105        }
106        closedir($handle);
107        return $results;
108    }
109
110    /**
111     * Given a class name exploded into an array, figure out the appropriate plugin
112     * manager to use.
113     *
114     * @param array $classParts Exploded class name array
115     *
116     * @return string
117     */
118    protected function getPluginManagerFromExplodedClassName($classParts)
119    {
120        $pmClass = $this->getPluginManagerForNamespace($classParts, 'VuFind');
121        // Special cases: no such service; use framework core services instead:
122        if ($pmClass === 'VuFind\Controller\PluginManager') {
123            return 'ControllerManager';
124        }
125        if ($pmClass === 'VuFind\Controller\Plugin\PluginManager') {
126            return \Laminas\Mvc\Controller\PluginManager::class;
127        }
128        // Special case: no such service; check other modules:
129        if (!class_exists($pmClass)) {
130            foreach ($this->getVuFindExtendedModules() as $module) {
131                $pmClass = $this->getPluginManagerForNamespace($classParts, $module);
132                if (class_exists($pmClass)) {
133                    break;
134                }
135            }
136        }
137        return $pmClass;
138    }
139
140    /**
141     * Given a class name exploded into an array, figure out the appropriate short
142     * name to use as an alias in the service manager configuration.
143     *
144     * @param array $classParts Exploded class name array
145     *
146     * @return string
147     */
148    protected function getShortNameFromExplodedClassName($classParts)
149    {
150        $shortName = array_pop($classParts);
151        // Special case: controllers use shortened aliases
152        if (($classParts[1] ?? '') === 'Controller') {
153            return preg_replace('/Controller$/', '', $shortName);
154        }
155        return strtolower($shortName);
156    }
157
158    /**
159     * Given a plugin manager object, return the interface plugins of that type must
160     * implement.
161     *
162     * @param ContainerInterface $pm Plugin manager
163     *
164     * @return string
165     */
166    protected function getExpectedInterfaceFromPluginManager($pm)
167    {
168        // Special case: controllers
169        if ($pm instanceof \Laminas\Mvc\Controller\ControllerManager) {
170            return \VuFind\Controller\AbstractBase::class;
171        }
172
173        // Special case: controller plugins:
174        if ($pm instanceof \Laminas\Mvc\Controller\PluginManager) {
175            return \Laminas\Mvc\Controller\Plugin\AbstractPlugin::class;
176        }
177
178        // Default case: look it up:
179        if (!method_exists($pm, 'getExpectedInterface')) {
180            return null;
181        }
182
183        // Force getExpectedInterface() to be public so we can read it:
184        $reflectionMethod = new \ReflectionMethod($pm, 'getExpectedInterface');
185        $reflectionMethod->setAccessible(true);
186        return $reflectionMethod->invoke($pm);
187    }
188
189    /**
190     * Given a plugin manager class name, return the configuration path for that
191     * plugin manager.
192     *
193     * @param string $class Class name
194     *
195     * @return array
196     */
197    protected function getConfigPathForClass($class)
198    {
199        // Special case: controller
200        if ($class === \Laminas\Mvc\Controller\ControllerManager::class) {
201            return ['controllers'];
202        } elseif ($class == \Laminas\Mvc\Controller\PluginManager::class) {
203            return ['controller_plugins'];
204        } elseif ($class == \Laminas\ServiceManager\ServiceManager::class) {
205            return ['service_manager'];
206        }
207        // Default case: VuFind internal plugin manager
208        $apmFactory = new \VuFind\ServiceManager\AbstractPluginManagerFactory();
209        $pmKey = $apmFactory->getConfigKey($class);
210        return ['vufind', 'plugin_managers', $pmKey];
211    }
212
213    /**
214     * Given appropriate inputs, figure out which plugin manager or service manager
215     * to use during plugin generation.
216     *
217     * @param ContainerInterface $container       Service manager
218     * @param array              $classParts      Exploded class name array
219     * @param bool               $topLevelService Set to true to build a service
220     * in the top-level container rather than a plugin in a subsidiary plugin manager
221     *
222     * @return ContainerInterface
223     */
224    protected function getPluginManagerForClassParts(
225        $container,
226        $classParts,
227        $topLevelService
228    ) {
229        // Special case -- short-circuit for top-level service:
230        if ($topLevelService) {
231            return $container;
232        }
233        $pmClass = $this->getPluginManagerFromExplodedClassName($classParts);
234        if (!$container->has($pmClass)) {
235            throw new \Exception(
236                'Cannot find expected plugin manager: ' . $pmClass . "\n"
237                . 'You can use the --top-level option if you wish to create'
238                . ' a top-level service.'
239            );
240        }
241        return $container->get($pmClass);
242    }
243
244    /**
245     * Create a plugin class.
246     *
247     * @param ContainerInterface $container       Service manager
248     * @param string             $class           Class name to create
249     * @param string             $factory         Existing factory to use (null to
250     * generate a new one)
251     * @param bool               $topLevelService Set to true to build a service
252     * in the top-level container rather than a plugin in a subsidiary plugin manager
253     *
254     * @return bool
255     * @throws \Exception
256     */
257    public function createPlugin(
258        ContainerInterface $container,
259        $class,
260        $factory = null,
261        $topLevelService = false
262    ) {
263        // Derive some key bits of information from the new class name:
264        $classParts = explode('\\', $class);
265        $module = $classParts[0];
266        $shortName = $this->getShortNameFromExplodedClassName($classParts);
267
268        // Set a flag for whether to generate a factory, and create class name
269        // if necessary. If existing factory specified, ensure it really exists.
270        if ($generateFactory = empty($factory)) {
271            $factory = $class . 'Factory';
272        } elseif (!class_exists($factory)) {
273            throw new \Exception("Undefined factory: $factory");
274        }
275
276        // Figure out further information based on the plugin manager:
277        $pm = $this->getPluginManagerForClassParts(
278            $container,
279            $classParts,
280            $topLevelService
281        );
282        $interface = $this->getExpectedInterfaceFromPluginManager($pm);
283
284        // Figure out whether the plugin requirement is an interface or a
285        // parent class so we can create the right thing....
286        if (interface_exists($interface)) {
287            $parent = null;
288            $interfaces = [$interface];
289        } else {
290            $parent = $interface;
291            $interfaces = [];
292        }
293        $configPath = $this->getConfigPathForClass($pm::class);
294
295        // Generate the classes and configuration:
296        $this->createClassInModule($class, $module, $parent, $interfaces);
297        if ($generateFactory) {
298            $this->generateFactory($factory, $module);
299        }
300        $factoryPath = array_merge($configPath, ['factories', $class]);
301        $aliasPath = array_merge($configPath, ['aliases', $shortName]);
302        $newConfigs = [
303            ['path' => $factoryPath, 'setting' => $factory],
304            ['path' => $aliasPath, 'setting' => $class],
305        ];
306        // Add extra lowercase alias if necessary:
307        if (strtolower($shortName) != $shortName) {
308            $lowerAliasPath = array_merge(
309                $configPath,
310                ['aliases', strtolower($shortName)]
311            );
312            $newConfigs[] = ['path' => $lowerAliasPath, 'setting' => $class];
313        }
314        $this->writeNewConfigs($newConfigs, $module, false);
315
316        return true;
317    }
318
319    /**
320     * Generate a factory class.
321     *
322     * @param string $factory Name of factory to generate
323     * @param string $module  Name of module to generate factory within
324     *
325     * @return void
326     */
327    protected function generateFactory($factory, $module)
328    {
329        $this->createClassInModule(
330            $factory,
331            $module,
332            null,
333            ['Laminas\ServiceManager\Factory\FactoryInterface'],
334            function ($generator) {
335                $method = MethodGenerator::fromArray(
336                    [
337                        'name' => '__invoke',
338                        'body' => 'return new $requestedName();',
339                    ]
340                );
341                $param1 = [
342                    'name' => 'container',
343                    'type' => 'Psr\Container\ContainerInterface',
344                ];
345                $param2 = [
346                    'name' => 'requestedName',
347                ];
348                $param3 = [
349                    'name' => 'options',
350                    'type' => 'array',
351                    'defaultValue' => null,
352                ];
353                $method->setParameters([$param1, $param2, $param3]);
354                // Copy doc block from this class' factory:
355                $reflection = new \Laminas\Code\Reflection\MethodReflection(
356                    GeneratorToolsFactory::class,
357                    '__invoke'
358                );
359                $example = MethodGenerator::fromReflection($reflection);
360                $method->setDocBlock($example->getDocBlock());
361                $generator->addMethods([$method]);
362            }
363        );
364    }
365
366    /**
367     * Extend a class defined somewhere in the service manager or its child
368     * plugin managers.
369     *
370     * @param ContainerInterface $container     Service manager
371     * @param string             $class         Class name to extend
372     * @param string             $target        Target module in which to create new
373     * service
374     * @param bool               $extendFactory Should we extend the factory?
375     *
376     * @return bool
377     * @throws \Exception
378     */
379    public function extendClass(
380        ContainerInterface $container,
381        $class,
382        $target,
383        $extendFactory = false
384    ) {
385        // Set things up differently depending on whether this is a top-level
386        // service or a class in a plugin manager.
387        $cm = $container->get('ControllerManager');
388        $cpm = $container->get('ControllerPluginManager');
389        $configPath = [];
390        $delegators = [];
391        if ($container->has($class)) {
392            $factory = $this->getFactoryFromContainer($container, $class);
393            $configPath = ['service_manager'];
394        } elseif ($factory = $this->getFactoryFromContainer($cm, $class)) {
395            $configPath = ['controllers'];
396        } elseif ($factory = $this->getFactoryFromContainer($cpm, $class)) {
397            $configPath = ['controller_plugins'];
398        } elseif ($pm = $this->getPluginManagerContainingClass($container, $class)) {
399            $apmFactory = new \VuFind\ServiceManager\AbstractPluginManagerFactory();
400            $pmKey = $apmFactory->getConfigKey($pm::class);
401            $factory = $this->getFactoryFromContainer($pm, $class);
402            $configPath = ['vufind', 'plugin_managers', $pmKey];
403            $delegators = $this->getDelegatorsFromContainer($pm, $class);
404        }
405
406        // No factory found? Throw an error!
407        if (empty($factory)) {
408            throw new \Exception('Could not find factory for ' . $class);
409        }
410
411        // Create the custom subclass.
412        $newClass = $this->createSubclassInModule($class, $target);
413
414        // Create the custom factory only if requested.
415        $newFactory = $extendFactory
416            ? $this->cloneFactory($factory, $target) : $factory;
417
418        // Finalize the local module configuration -- create a factory for the
419        // new class, and set up the new class as an alias for the old class.
420        $factoryPath = array_merge($configPath, ['factories', $newClass]);
421        $aliasPath = array_merge($configPath, ['aliases', $class]);
422        $newConfigs = [
423            ['path' => $factoryPath, 'setting' => $newFactory],
424            ['path' => $aliasPath, 'setting' => $newClass],
425        ];
426
427        // Clone/configure delegator factories as needed.
428        if (!empty($delegators)) {
429            $newDelegators = [];
430            foreach ($delegators as $delegator) {
431                $newDelegators[] = $extendFactory
432                    ? $this->cloneFactory($delegator, $target) : $delegator;
433            }
434            $delegatorPath = array_merge($configPath, ['delegators', $newClass]);
435            $newConfigs[] = ['path' => $delegatorPath, 'setting' => $newDelegators];
436        }
437        $this->writeNewConfigs($newConfigs, $target, false);
438
439        return true;
440    }
441
442    /**
443     * Get a list of factories in the provided container.
444     *
445     * @param ContainerInterface $container Container to inspect
446     *
447     * @return array
448     */
449    protected function getAllFactoriesFromContainer(ContainerInterface $container)
450    {
451        // There is no "getFactories" method, so we need to use reflection:
452        $reflectionProperty = new \ReflectionProperty($container, 'factories');
453        $reflectionProperty->setAccessible(true);
454        return $reflectionProperty->getValue($container);
455    }
456
457    /**
458     * Get a factory from the provided container (or null if undefined).
459     *
460     * @param ContainerInterface $container Container to inspect
461     * @param string             $class     Class whose factory we want
462     *
463     * @return string
464     */
465    protected function getFactoryFromContainer(ContainerInterface $container, $class)
466    {
467        $factories = $this->getAllFactoriesFromContainer($container);
468        return $factories[$class] ?? null;
469    }
470
471    /**
472     * Get a list of delegators in the provided container.
473     *
474     * @param ContainerInterface $container Container to inspect
475     *
476     * @return array
477     */
478    protected function getAllDelegatorsFromContainer(ContainerInterface $container)
479    {
480        // There is no "getDelegators" method, so we need to use reflection:
481        $reflectionProperty = new \ReflectionProperty($container, 'delegators');
482        $reflectionProperty->setAccessible(true);
483        return $reflectionProperty->getValue($container);
484    }
485
486    /**
487     * Get delegators from the provided container (or empty array if undefined).
488     *
489     * @param ContainerInterface $container Container to inspect
490     * @param string             $class     Class whose delegators we want
491     *
492     * @return array
493     */
494    protected function getDelegatorsFromContainer(
495        ContainerInterface $container,
496        $class
497    ) {
498        $delegators = $this->getAllDelegatorsFromContainer($container);
499        return $delegators[$class] ?? [];
500    }
501
502    /**
503     * Search all plugin managers for one containing the requested class (or return
504     * null if none found).
505     *
506     * @param ContainerInterface $container Service manager
507     * @param string             $class     Class to search for
508     *
509     * @return ContainerInterface
510     */
511    protected function getPluginManagerContainingClass(
512        ContainerInterface $container,
513        $class
514    ) {
515        $factories = $this->getAllFactoriesFromContainer($container);
516        foreach (array_keys($factories) as $service) {
517            if (str_ends_with($service, 'PluginManager')) {
518                $pm = $container->get($service);
519                if (null !== $this->getFactoryFromContainer($pm, $class)) {
520                    return $pm;
521                }
522            }
523        }
524        return null;
525    }
526
527    /**
528     * Extend a service defined in module.config.php.
529     *
530     * @param string $source Configuration path to use as source
531     * @param string $target Target module in which to create new service
532     *
533     * @return bool
534     * @throws \Exception
535     */
536    public function extendService($source, $target)
537    {
538        $parts = explode('/', $source);
539        $partCount = count($parts);
540        if ($partCount < 3) {
541            throw new \Exception('Config path too short.');
542        }
543        $sourceType = $parts[$partCount - 2];
544
545        $supportedTypes = ['factories', 'invokables'];
546        if (!in_array($sourceType, $supportedTypes)) {
547            throw new \Exception(
548                'Unsupported service type; supported values: '
549                . implode(', ', $supportedTypes)
550            );
551        }
552
553        $config = $this->retrieveConfig($parts);
554        if (!$config) {
555            throw new \Exception("{$source} not found in configuration.");
556        }
557
558        switch ($sourceType) {
559            case 'factories':
560                $this->createSubclassInModule($parts[$partCount - 1], $target);
561                $newConfig = $this->cloneFactory($config, $target);
562                break;
563            case 'invokables':
564                $newConfig = $this->createSubclassInModule($config, $target);
565                break;
566            default:
567                throw new \Exception('Reached unreachable code!');
568        }
569        $this->writeNewConfig($parts, $newConfig, $target);
570        return true;
571    }
572
573    /**
574     * Create a new subclass and factory to override a factory-generated
575     * service.
576     *
577     * @param mixed  $factory Factory configuration for class to extend
578     * @param string $module  Module in which to create the new factory
579     *
580     * @return string
581     * @throws \Exception
582     */
583    protected function cloneFactory($factory, $module)
584    {
585        // If the factory is a stand-alone class, it's simple to clone:
586        if (class_exists($factory)) {
587            return $this->createSubclassInModule($factory, $module);
588        }
589
590        // Make sure we can figure out how to handle the factory; it should
591        // either be a [controller, method] array or a "controller::method"
592        // string; anything else will cause a problem.
593        $parts = is_string($factory) ? explode('::', $factory) : $factory;
594        if (
595            !is_array($parts) || count($parts) != 2 || !class_exists($parts[0])
596            || !is_callable($parts)
597        ) {
598            throw new \Exception('Unexpected factory configuration format.');
599        }
600        [$factoryClass, $factoryMethod] = $parts;
601        $newFactoryClass = $this->generateLocalClassName($factoryClass, $module);
602        if (!class_exists($newFactoryClass)) {
603            $this->createSubclassInModule($factoryClass, $module);
604            $skipBackup = true;
605        } else {
606            $skipBackup = false;
607        }
608
609        $oldReflection = new ClassReflection($factoryClass);
610        $newReflection = new ClassReflection($newFactoryClass);
611
612        try {
613            $newMethod = $newReflection->getMethod($factoryMethod);
614            if ($newMethod->getDeclaringClass()->getName() == $newFactoryClass) {
615                throw new \Exception(
616                    "$newFactoryClass::$factoryMethod already exists."
617                );
618            }
619
620            $generator = ClassGenerator::fromReflection($newReflection);
621            $method = MethodGenerator::fromReflection(
622                $oldReflection->getMethod($factoryMethod)
623            );
624            $this->updateFactory(
625                $method,
626                $oldReflection->getNamespaceName(),
627                $module
628            );
629            $generator->addMethodFromGenerator($method);
630            $this->writeClass($generator, $module, true, $skipBackup);
631        } catch (\ReflectionException $e) {
632            // If a parent factory has a __callStatic method, the method we are
633            // trying to rewrite may not exist. In that case, we can just inherit
634            // __callStatic and ignore the error. Any other exception should be
635            // treated as a fatal error.
636            if (method_exists($factoryClass, '__callStatic')) {
637                $this->writeln('Error: ' . $e->getMessage());
638                $this->writeln(
639                    '__callStatic in parent factory; skipping method generation.'
640                );
641            } else {
642                throw $e;
643            }
644        }
645
646        return $newFactoryClass . '::' . $factoryMethod;
647    }
648
649    /**
650     * Given a factory method, extend the class being constructed and create
651     * a new factory for the subclass.
652     *
653     * @param MethodGenerator $method Method to modify
654     * @param string          $ns     Namespace of old factory
655     * @param string          $module Module in which to make changes
656     *
657     * @return void
658     * @throws \Exception
659     */
660    protected function updateFactory(
661        MethodGenerator $method,
662        $ns,
663        $module
664    ) {
665        $body = $method->getBody();
666        $regex = '/new\s+([\w\\\\]*)\s*\(/m';
667        preg_match_all($regex, $body, $matches);
668        $classNames = $matches[1];
669        $count = count($classNames);
670        if ($count != 1) {
671            throw new \Exception("Found $count class names; expected 1.");
672        }
673        $className = $classNames[0];
674        // Figure out fully qualified name for purposes of createSubclassInModule():
675        $fqClassName = (!str_starts_with($className, '\\'))
676            ? "$ns\\$className" : $className;
677        $newClass = $this->generateLocalClassName($fqClassName, $module);
678        $body = preg_replace(
679            '/new\s+' . addslashes($className) . '\s*\(/m',
680            'new \\' . $newClass . '(',
681            $body
682        );
683        $method->setBody($body);
684    }
685
686    /**
687     * Determine the name of a local replacement class within the specified
688     * module.
689     *
690     * @param string $class  Name of class to extend/replace
691     * @param string $module Module in which to create the new class
692     *
693     * @return string
694     * @throws \Exception
695     */
696    protected function generateLocalClassName($class, $module)
697    {
698        // Determine the name of the new class by exploding the old class and
699        // replacing the namespace:
700        $parts = explode('\\', trim($class, '\\'));
701        if (count($parts) < 2) {
702            throw new \Exception('Expected a namespaced class; found ' . $class);
703        }
704        $parts[0] = $module;
705        return implode('\\', $parts);
706    }
707
708    /**
709     * Extend a specified class within a specified module. Return the name of
710     * the new subclass.
711     *
712     * @param string   $class      Name of class to create
713     * @param string   $module     Module in which to create the new class
714     * @param string   $parent     Parent class (null for no parent)
715     * @param string[] $interfaces Interfaces for class to implement
716     * @param callable $callback   Callback to set up class generator
717     *
718     * @return void
719     * @throws \Exception
720     */
721    protected function createClassInModule(
722        $class,
723        $module,
724        $parent = null,
725        array $interfaces = [],
726        $callback = null
727    ) {
728        $generator = new ClassGenerator($class, null, null, $parent, $interfaces);
729        if (is_callable($callback)) {
730            $callback($generator);
731        }
732        $this->writeClass($generator, $module);
733    }
734
735    /**
736     * Write a class to disk.
737     *
738     * @param ClassGenerator $classGenerator Representation of class to write
739     * @param string         $module         Module in which to write class
740     * @param bool           $allowOverwrite Allow overwrite of existing file?
741     * @param bool           $skipBackup     Should we skip backing up the file?
742     *
743     * @return void
744     * @throws \Exception
745     */
746    protected function writeClass(
747        ClassGenerator $classGenerator,
748        $module,
749        $allowOverwrite = false,
750        $skipBackup = false
751    ) {
752        // Use the class name parts from the previous step to determine a path
753        // and filename, then create the new path.
754        $parts = explode('\\', $classGenerator->getNamespaceName());
755        array_unshift($parts, 'module', $module, 'src');
756        $this->createTree($parts);
757
758        // Generate the new class:
759        $generator = FileGenerator::fromArray(['classes' => [$classGenerator]]);
760        $filename = $classGenerator->getName() . '.php';
761        $fullPath = APPLICATION_PATH . '/' . implode('/', $parts) . '/' . $filename;
762        if (file_exists($fullPath)) {
763            if ($allowOverwrite) {
764                if (!$skipBackup) {
765                    $this->backUpFile($fullPath);
766                }
767            } else {
768                throw new \Exception("$fullPath already exists.");
769            }
770        }
771        // TODO: this is a workaround for an apparent bug in Laminas\Code which
772        // omits the leading backslash on "extends" statements when rewriting
773        // existing classes. Can we remove this after a future Laminas\Code upgrade?
774        $code = str_replace(
775            'extends VuFind\\',
776            'extends \\VuFind\\',
777            $generator->generate()
778        );
779        if (!file_put_contents($fullPath, $code)) {
780            throw new \Exception("Problem writing to $fullPath.");
781        }
782        $this->writeln("Saved file: $fullPath");
783    }
784
785    /**
786     * Extend a specified class within a specified module. Return the name of
787     * the new subclass.
788     *
789     * @param string $class  Name of class to extend
790     * @param string $module Module in which to create the new class
791     *
792     * @return string
793     * @throws \Exception
794     */
795    protected function createSubclassInModule($class, $module)
796    {
797        // Normalize leading backslashes; in some contexts we will
798        // have them and in others we may not.
799        $class = trim($class, '\\');
800        $newClass = $this->generateLocalClassName($class, $module);
801        $this->createClassInModule($newClass, $module, "\\$class");
802        return $newClass;
803    }
804
805    /**
806     * Create a directory tree.
807     *
808     * @param array $path Array of subdirectories to create relative to
809     * APPLICATION_PATH
810     *
811     * @return void
812     * @throws \Exception
813     */
814    protected function createTree($path)
815    {
816        $fullPath = APPLICATION_PATH;
817        foreach ($path as $part) {
818            $fullPath .= '/' . $part;
819            if (!file_exists($fullPath)) {
820                if (!mkdir($fullPath)) {
821                    throw new \Exception("Problem creating $fullPath");
822                }
823            }
824            if (!is_dir($fullPath)) {
825                throw new \Exception("$fullPath is not a directory!");
826            }
827        }
828    }
829
830    /**
831     * Create a backup of a file.
832     *
833     * @param string $filename File to back up
834     *
835     * @return void
836     * @throws \Exception
837     */
838    public function backUpFile($filename)
839    {
840        $backup = $filename . '.' . time() . '.bak';
841        if (!copy($filename, $backup)) {
842            throw new \Exception("Problem generating backup file: $backup");
843        }
844        $this->writeln("Created backup: $backup");
845    }
846
847    /**
848     * Get the path to the module configuration; throw an exception if it is
849     * missing.
850     *
851     * @param string $module Module name
852     *
853     * @return string
854     * @throws \Exception
855     */
856    public function getModuleConfigPath($module)
857    {
858        $configPath = APPLICATION_PATH . "/module/$module/config/module.config.php";
859        if (!file_exists($configPath)) {
860            throw new \Exception("Cannot find $configPath");
861        }
862        return $configPath;
863    }
864
865    /**
866     * Write a module configuration.
867     *
868     * @param string $configPath Path to write to
869     * @param string $config     Configuration array to write
870     *
871     * @return void
872     * @throws \Exception
873     */
874    public function writeModuleConfig($configPath, $config)
875    {
876        $generator = FileGenerator::fromArray(
877            [
878                'body' => 'return ' . var_export($config, true) . ';',
879            ]
880        );
881        if (!file_put_contents($configPath, $generator->generate())) {
882            throw new \Exception("Cannot write to $configPath");
883        }
884        $this->writeln("Successfully updated $configPath");
885    }
886
887    /**
888     * Apply a single setting to a configuration array.
889     *
890     * @param array        $path    Representation of path in config array
891     * @param string|array $setting New setting to write into config
892     * @param array        $config  Configuration array (passed by reference)
893     *
894     * @return void
895     */
896    protected function applySettingToConfig(
897        array $path,
898        $setting,
899        array &$config
900    ) {
901        $current = & $config;
902        $finalStep = array_pop($path);
903        foreach ($path as $step) {
904            if (!is_array($current)) {
905                throw new \Exception('Unexpected non-array: ' . $current);
906            }
907            if (!isset($current[$step])) {
908                $current[$step] = [];
909            }
910            $current = & $current[$step];
911        }
912        if (!is_array($current)) {
913            throw new \Exception('Unexpected non-array: ' . $current);
914        }
915        $current[$finalStep] = $setting;
916    }
917
918    /**
919     * Update the configuration of a target module with multiple settings.
920     *
921     * @param array  $newValues An array of arrays containing 'path' and 'setting'
922     * keys to specify changes to the configuration.
923     * @param string $module    Module in which to write the configuration
924     * @param bool   $backup    Should we back up the existing config?
925     *
926     * @return void
927     * @throws \Exception
928     */
929    protected function writeNewConfigs(
930        array $newValues,
931        string $module,
932        bool $backup = true
933    ) {
934        // Create backup of configuration
935        $configPath = $this->getModuleConfigPath($module);
936        if ($backup) {
937            $this->backUpFile($configPath);
938        }
939
940        $config = include $configPath;
941        foreach ($newValues as $current) {
942            $this->applySettingToConfig(
943                $current['path'],
944                $current['setting'],
945                $config
946            );
947        }
948
949        // Write updated configuration
950        $this->writeModuleConfig($configPath, $config);
951    }
952
953    /**
954     * Update the configuration of a target module with a single setting.
955     *
956     * @param array        $path    Representation of path in config array
957     * @param string|array $setting New setting to write into config
958     * @param string       $module  Module in which to write the configuration
959     * @param bool         $backup  Should we back up the existing config?
960     *
961     * @return void
962     * @throws \Exception
963     */
964    protected function writeNewConfig($path, $setting, $module, $backup = true)
965    {
966        $this->writeNewConfigs([compact('path', 'setting')], $module, $backup);
967    }
968
969    /**
970     * Retrieve a value from the application configuration (or return false
971     * if the path is not found).
972     *
973     * @param array $path Path to walk through configuration
974     *
975     * @return mixed
976     */
977    protected function retrieveConfig(array $path)
978    {
979        $config = $this->config;
980        foreach ($path as $part) {
981            if (!isset($config[$part])) {
982                return false;
983            }
984            $config = $config[$part];
985        }
986        return $config;
987    }
988}