Passed
Push — master ( 23a26d...cc7dc4 )
by Melech
04:05
created

Router::askToRunSimilarCommands()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 29
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 19
c 1
b 0
f 0
dl 0
loc 29
rs 9.6333
cc 2
nc 1
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the Valkyrja Framework package.
7
 *
8
 * (c) Melech Mizrachi <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Valkyrja\Cli\Routing;
15
16
use Override;
0 ignored issues
show
Bug introduced by
The type Override was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
17
use Valkyrja\Cli\Interaction\Enum\ExitCode;
18
use Valkyrja\Cli\Interaction\Factory\Contract\OutputFactory;
19
use Valkyrja\Cli\Interaction\Input\Contract\Input;
20
use Valkyrja\Cli\Interaction\Message\Answer;
21
use Valkyrja\Cli\Interaction\Message\Banner;
22
use Valkyrja\Cli\Interaction\Message\Contract\Answer as AnswerContract;
23
use Valkyrja\Cli\Interaction\Message\ErrorMessage;
24
use Valkyrja\Cli\Interaction\Message\NewLine;
25
use Valkyrja\Cli\Interaction\Message\Question;
26
use Valkyrja\Cli\Interaction\Option\Option;
27
use Valkyrja\Cli\Interaction\Output\Contract\Output;
28
use Valkyrja\Cli\Middleware;
29
use Valkyrja\Cli\Middleware\Handler\Contract\CommandDispatchedHandler;
30
use Valkyrja\Cli\Middleware\Handler\Contract\CommandMatchedHandler;
31
use Valkyrja\Cli\Middleware\Handler\Contract\CommandNotMatchedHandler;
32
use Valkyrja\Cli\Middleware\Handler\Contract\ExitedHandler;
33
use Valkyrja\Cli\Middleware\Handler\Contract\ThrowableCaughtHandler;
34
use Valkyrja\Cli\Routing\Collection\Contract\Collection;
35
use Valkyrja\Cli\Routing\Command\HelpCommand;
0 ignored issues
show
Bug introduced by
The type Valkyrja\Cli\Routing\Command\HelpCommand was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
36
use Valkyrja\Cli\Routing\Contract\Router as Contract;
37
use Valkyrja\Cli\Routing\Data\Contract\Command;
38
use Valkyrja\Cli\Routing\Data\Option\HelpOptionParameter;
0 ignored issues
show
Bug introduced by
The type Valkyrja\Cli\Routing\Dat...ion\HelpOptionParameter was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
39
use Valkyrja\Cli\Routing\Enum\ArgumentValueMode;
40
use Valkyrja\Cli\Routing\Exception\RuntimeException;
41
use Valkyrja\Container\Contract\Container;
42
use Valkyrja\Dispatcher\Contract\Dispatcher;
43
44
use function in_array;
45
46
/**
47
 * Class Router.
48
 *
49
 * @author Melech Mizrachi
50
 */
51
class Router implements Contract
52
{
53
    public function __construct(
54
        protected Container $container = new \Valkyrja\Container\Container(),
55
        protected Dispatcher $dispatcher = new \Valkyrja\Dispatcher\Dispatcher(),
56
        protected Collection $collection = new \Valkyrja\Cli\Routing\Collection\Collection(),
57
        protected OutputFactory $outputFactory = new \Valkyrja\Cli\Interaction\Factory\OutputFactory(),
58
        protected ThrowableCaughtHandler $throwableCaughtHandler = new Middleware\Handler\ThrowableCaughtHandler(),
59
        protected CommandMatchedHandler $commandMatchedHandler = new Middleware\Handler\CommandMatchedHandler(),
60
        protected CommandNotMatchedHandler $commandNotMatchedHandler = new Middleware\Handler\CommandNotMatchedHandler(),
61
        protected CommandDispatchedHandler $commandDispatchedHandler = new Middleware\Handler\CommandDispatchedHandler(),
62
        protected ExitedHandler $exitedHandler = new Middleware\Handler\ExitedHandler(),
63
    ) {
64
    }
65
66
    /**
67
     * @inheritDoc
68
     */
69
    #[Override]
70
    public function dispatch(Input $input): Output
71
    {
72
        // Attempt to match the command
73
        $matchedCommand = $this->attemptToMatchCommand($input);
74
75
        // If the command was not matched an output returned
76
        if ($matchedCommand instanceof Output) {
77
            // Dispatch RouteNotMatchedMiddleware
78
            return $this->commandNotMatchedHandler->commandNotMatched(
79
                input: $input,
80
                output: $matchedCommand
81
            );
82
        }
83
84
        return $this->dispatchCommand(
85
            input: $input,
86
            command: $matchedCommand
87
        );
88
    }
89
90
    /**
91
     * @inheritDoc
92
     */
93
    #[Override]
94
    public function dispatchCommand(Input $input, Command $command): Output
95
    {
96
        // The command has been matched
97
        $this->commandMatched($command);
98
99
        // Dispatch the RouteMatchedMiddleware
100
        $commandAfterMiddleware = $this->commandMatchedHandler->commandMatched(
101
            input: $input,
102
            command: $command
103
        );
104
105
        // If the return value after middleware is an output return it
106
        if ($commandAfterMiddleware instanceof Output) {
107
            return $commandAfterMiddleware;
108
        }
109
110
        // Set the command after middleware has potentially modified it in the service container
111
        $this->container->setSingleton(Command::class, $commandAfterMiddleware);
112
113
        $dispatch  = $commandAfterMiddleware->getDispatch();
114
        $arguments = $dispatch->getArguments();
115
116
        // Attempt to dispatch the route using any one of the callable options
117
        $output = $this->dispatcher->dispatch(
118
            dispatch: $dispatch,
119
            arguments: $arguments
120
        );
121
122
        if (! $output instanceof Output) {
123
            throw new RuntimeException('All commands must return an output');
124
        }
125
126
        return $this->commandDispatchedHandler->commandDispatched(
127
            input: $input,
128
            output: $output,
129
            command: $commandAfterMiddleware
130
        );
131
    }
132
133
    /**
134
     * Match a command, or a response if no command exists, from a given input.
135
     */
136
    protected function attemptToMatchCommand(Input $input): Command|Output
137
    {
138
        $commandName = $input->getCommandName();
139
140
        // Try to get the command
141
        $command = $this->collection->get(
142
            name: $commandName
143
        );
144
145
        // Return the command if it was found
146
        if ($command !== null) {
147
            if (
148
                $input->hasOption(HelpOptionParameter::NAME)
149
                || $input->hasOption(HelpOptionParameter::SHORT_NAME)
150
            ) {
151
                $command = $this->collection->get(name: HelpCommand::NAME);
152
                $input   = $input->withOptions(
153
                    new Option('command', $commandName),
154
                );
155
156
                if ($command === null) {
157
                    throw new RuntimeException('Help command does not exist');
158
                }
159
            }
160
161
            return $this->addParametersToCommand($input, $command);
162
        }
163
164
        $errorText = "Command `$commandName` was not found.";
165
166
        $output = $this->outputFactory
167
            ->createOutput(exitCode: ExitCode::ERROR)
168
            ->withMessages(
169
                new Banner(new ErrorMessage($errorText))
170
            );
171
172
        return $this->checkCommandNameForTypo($input, $output);
173
    }
174
175
    /**
176
     * Add the parameters from the input to the command.
177
     */
178
    protected function addParametersToCommand(Input $input, Command $command): Command
179
    {
180
        $command = $this->addArgumentsToCommand($input, $command);
181
182
        return $this->addOptionsToCommand($input, $command);
183
    }
184
185
    /**
186
     * Add the arguments from the input to the command.
187
     */
188
    protected function addArgumentsToCommand(Input $input, Command $command): Command
189
    {
190
        $arguments          = [...$input->getArguments()];
191
        $argumentParameters = $command->getArguments();
192
193
        foreach ($argumentParameters as $key => $argumentParameter) {
194
            $argumentParameterArguments = [];
195
196
            // Array arguments must be last, and will take up all the remaining arguments from the input
197
            if ($argumentParameter->getValueMode() === ArgumentValueMode::ARRAY) {
198
                $argumentParameterArguments = $arguments;
199
200
                $arguments = [];
201
            } elseif (isset($arguments[$key])) {
202
                // If not an array type then we should match each argument in order of appearance
203
                $argumentParameterArguments[] = $arguments[$key];
204
205
                unset($arguments[$key]);
206
            }
207
208
            $argumentParameters[$key] = $argumentParameter
209
                ->withArguments(...$argumentParameterArguments)
0 ignored issues
show
Bug introduced by
$argumentParameterArguments of type Valkyrja\Cli\Interaction...ent\Contract\Argument[] is incompatible with the type Valkyrja\Cli\Interaction...ument\Contract\Argument expected by parameter $arguments of Valkyrja\Cli\Routing\Dat...ameter::withArguments(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

209
                ->withArguments(/** @scrutinizer ignore-type */ ...$argumentParameterArguments)
Loading history...
210
                ->validateValues();
211
        }
212
213
        return $command
214
            ->withArguments(...$argumentParameters);
215
    }
216
217
    /**
218
     * Add the options from the input to the command.
219
     */
220
    protected function addOptionsToCommand(Input $input, Command $command): Command
221
    {
222
        $options          = $input->getOptions();
223
        $optionParameters = [...$command->getOptions()];
224
225
        foreach ($optionParameters as $key => $optionParameter) {
226
            $optionParameterOptions = [];
227
228
            foreach ($options as $option) {
229
                // Add the option only if it matches the name or one of the short names
230
                if (
231
                    $optionParameter->getName() === $option->getName()
232
                    || in_array($option->getName(), $optionParameter->getShortNames(), true)
233
                ) {
234
                    $optionParameterOptions[] = $option;
235
                }
236
            }
237
238
            $optionParameters[$key] = $optionParameter
239
                ->withOptions(...$optionParameterOptions)
240
                ->validateValues();
241
        }
242
243
        return $command
244
            ->withOptions(...$optionParameters);
245
    }
246
247
    /**
248
     * Check the command name from the input for a typo.
249
     */
250
    protected function checkCommandNameForTypo(Input $input, Output $output): Command|Output
251
    {
252
        $name = $input->getCommandName();
253
254
        $commands = [];
255
256
        foreach ($this->collection->all() as $command) {
257
            similar_text($command->getName(), $name, $percent);
258
259
            if ($percent >= 60) {
260
                $commands[] = $command;
261
            }
262
        }
263
264
        if ($commands !== []) {
265
            return $this->askToRunSimilarCommands($output, $commands);
266
        }
267
268
        return $output;
269
    }
270
271
    /**
272
     * Ask the user if they want to run similar commands.
273
     *
274
     * @param Command[] $commands The list of commands
275
     */
276
    protected function askToRunSimilarCommands(Output $output, array $commands): Command|Output
277
    {
278
        $command = null;
279
280
        $commandNames = array_map(static fn (Command $command) => $command->getName(), $commands);
281
282
        $output = $output
283
            ->withAddedMessages(
284
                new NewLine(),
285
                new Question(
286
                    'Did you mean to run one of the following commands?',
287
                    function (Output $output, AnswerContract $answer) use (&$command, $commands): Output {
288
                        $response = $answer->getUserResponse();
289
                        $command  = $response !== 'no'
290
                            ? array_filter($commands, static fn (Command $command): bool => $command->getName() === $response)[0] ?? null
291
                            : null;
292
293
                        return $output;
294
                    },
295
                    new Answer(
296
                        defaultResponse: 'no',
297
                        allowedResponses: $commandNames
298
                    ),
299
                ),
300
            )
301
            ->writeMessages();
302
303
        return $command
304
            ?? $output;
305
    }
306
307
    /**
308
     * Do various stuff after the route has been matched.
309
     */
310
    protected function commandMatched(Command $command): void
311
    {
312
        $this->commandMatchedHandler->add(...$command->getCommandMatchedMiddleware());
313
        $this->commandDispatchedHandler->add(...$command->getCommandDispatchedMiddleware());
314
        $this->throwableCaughtHandler->add(...$command->getThrowableCaughtMiddleware());
315
        $this->exitedHandler->add(...$command->getExitedMiddleware());
316
317
        // Set the found command in the service container
318
        $this->container->setSingleton(Command::class, $command);
319
    }
320
}
321