Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
28 / 30
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
DevToolsCommandLoader
93.33% covered (success)
93.33%
28 / 30
50.00% covered (danger)
50.00%
1 / 2
10.03
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCommandMap
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
9.03
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\CommandLoader;
21
22use FastForward\DevTools\Filesystem\FinderFactoryInterface;
23use FastForward\DevTools\Reflection\ClassReflection;
24use Psr\Container\ContainerInterface;
25use RuntimeException;
26use Symfony\Component\Console\Attribute\AsCommand;
27use Symfony\Component\Console\Command\Command;
28use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
29
30/**
31 * Responsible for dynamically discovering and loading Symfony Console commands
32 * within the DevTools context. This class extends the ContainerCommandLoader
33 * and integrates with a PSR-11 compatible container to lazily instantiate commands.
34 *
35 * The implementation MUST scan a predefined directory for PHP classes representing
36 * console commands and SHALL only register classes that:
37 * - Are instantiable
38 * - Extend the Symfony\Component\Console\Command\Command base class
39 * - Declare the Symfony\Component\Console\Attribute\AsCommand attribute
40 *
41 * The command name MUST be extracted from the AsCommand attribute metadata and
42 * used as the key in the command map. Classes that do not meet these criteria
43 * MUST NOT be included in the command map.
44 */
45 class DevToolsCommandLoader extends ContainerCommandLoader
46{
47    /**
48     * Constructs the DevToolsCommandLoader.
49     *
50     * This constructor initializes the command loader by scanning the Command directory for classes that are
51     * instantiable and have the AsCommand attribute.
52     * It builds a command map associating command names with their respective classes.
53     *
54     * @param FinderFactoryInterface $finderFactory
55     * @param ContainerInterface $container
56     */
57    public function __construct(FinderFactoryInterface $finderFactory, ContainerInterface $container)
58    {
59        parent::__construct($container, $this->getCommandMap($finderFactory));
60    }
61
62    /**
63     * Builds a command map by scanning the Command directory for classes that are instantiable and have the AsCommand attribute.
64     *
65     * @param FinderFactoryInterface $finderFactory
66     *
67     * @return array
68     */
69    private function getCommandMap(FinderFactoryInterface $finderFactory): array
70    {
71        $commandMap = [];
72
73        $commandsDirectory = $finderFactory
74            ->create()
75            ->files()
76            ->in(__DIR__ . '/../Command')
77            ->notPath('Traits')
78            ->name('*.php');
79
80        $namespace = substr(__NAMESPACE__, 0, strrpos(__NAMESPACE__, '\\')) . '\\Command\\';
81
82        foreach ($commandsDirectory as $file) {
83            $class = $namespace . $file->getBasename('.php');
84            if (! ClassReflection::isInstantiableSubclassOf($class, Command::class)) {
85                continue;
86            }
87
88            $arguments = ClassReflection::getAttributeArguments($class, AsCommand::class);
89
90            if (null === $arguments) {
91                continue;
92            }
93
94            $commandNames = [$arguments['name'], ...((array) $arguments['aliases'])];
95
96            foreach ($commandNames as $commandName) {
97                if (! \is_string($commandName)) {
98                    continue;
99                }
100
101                if ('' === $commandName) {
102                    continue;
103                }
104
105                if (\array_key_exists($commandName, $commandMap) && $commandMap[$commandName] !== $class) {
106                    throw new RuntimeException(\sprintf(
107                        'Command %s is already registered and cannot be assigned to %s.',
108                        $commandName,
109                        $class
110                    ));
111                }
112
113                $commandMap[$commandName] = $class;
114            }
115        }
116
117        return $commandMap;
118    }
119}