Completed
Pull Request — master (#90)
by
unknown
01:18
created

CompletionHandler::getCommands()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 25
rs 9.52
c 0
b 0
f 0
cc 4
nc 4
nop 0
1
<?php
2
3
namespace Stecman\Component\Symfony\Console\BashCompletion;
4
5
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface;
6
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionInterface;
7
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionResult;
8
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionResultInterface;
9
use Stecman\Component\Symfony\Console\BashCompletion\Completion\DescriptiveCompletion;
10
use Symfony\Component\Console\Application;
11
use Symfony\Component\Console\Command\Command;
12
use Symfony\Component\Console\Input\ArrayInput;
13
use Symfony\Component\Console\Input\InputArgument;
14
use Symfony\Component\Console\Input\InputOption;
15
16
class CompletionHandler
17
{
18
    /**
19
     * Application to complete for
20
     *
21
     * @var \Symfony\Component\Console\Application
22
     */
23
    protected $application;
24
25
    /**
26
     * @var Command
27
     */
28
    protected $command;
29
30
    /**
31
     * @var CompletionContext
32
     */
33
    protected $context;
34
35
    /**
36
     * Array of completion helpers.
37
     *
38
     * @var CompletionInterface[]
39
     */
40
    protected $helpers = array();
41
42
    /**
43
     * Index the command name was detected at
44
     *
45
     * @var int
46
     */
47
    private $commandWordIndex;
48
49
    public function __construct(Application $application, CompletionContext $context = null)
50
    {
51
        $this->application = $application;
52
        $this->context = $context;
53
54
        // Set up completions for commands that are built-into Application
55
        $this->addHandler(
56
            new \Stecman\Component\Symfony\Console\BashCompletion\Completion\DescriptiveCompletion(
57
                'help',
58
                'command_name',
59
                Completion::TYPE_ARGUMENT,
60
                $this->getCommands()
61
            )
62
        );
63
64
        $this->addHandler(
65
            new Completion(
66
                'list',
67
                'namespace',
68
                Completion::TYPE_ARGUMENT,
69
                $application->getNamespaces()
70
            )
71
        );
72
    }
73
74
    public function setContext(CompletionContext $context)
75
    {
76
        $this->context = $context;
77
    }
78
79
    /**
80
     * @return CompletionContext
81
     */
82
    public function getContext()
83
    {
84
        return $this->context;
85
    }
86
87
    /**
88
     * @param CompletionInterface[] $array
89
     */
90
    public function addHandlers(array $array)
91
    {
92
        $this->helpers = array_merge($this->helpers, $array);
93
    }
94
95
    /**
96
     * @param CompletionInterface $helper
97
     */
98
    public function addHandler(CompletionInterface $helper)
99
    {
100
        $this->helpers[] = $helper;
101
    }
102
103
    /**
104
     * Do the actual completion, returning an array of strings to provide to the parent shell's completion system
105
     *
106
     * @return CompletionResultInterface
107
     * @throws \RuntimeException
108
     */
109
    public function runCompletion()
110
    {
111
        if (!$this->context) {
112
            throw new \RuntimeException('A CompletionContext must be set before requesting completion.');
113
        }
114
115
        // Set the command to query options and arugments from
116
        $this->command = $this->detectCommand();
117
118
        $process = array(
119
            'completeForOptionValues',
120
            'completeForOptionShortcuts',
121
            'completeForOptionShortcutValues',
122
            'completeForOptions',
123
            'completeForCommandName',
124
            'completeForCommandArguments'
125
        );
126
127
        foreach ($process as $methodName) {
128
            $result = $this->{$methodName}();
129
130
            if (false !== $result) {
131
                if (!$result instanceof CompletionResultInterface) {
132
                    $result = new CompletionResult((array)$result);
133
                }
134
                return $this->filterResult($result);
135
            }
136
        }
137
138
        return new CompletionResult(array());
139
    }
140
141
    /**
142
     * Attempt to complete the current word as a long-form option (--my-option)
143
     *
144
     * @return array|false
145
     */
146
    protected function completeForOptions()
147
    {
148
        $word = $this->context->getCurrentWord();
149
150
        if (substr($word, 0, 2) === '--') {
151
            $options = array();
152
153
            foreach ($this->getAllOptions() as $opt) {
154
                $options[] = '--' . $opt->getName();
155
            }
156
157
            return $options;
158
        }
159
160
        return false;
161
    }
162
163
    /**
164
     * Attempt to complete the current word as an option shortcut.
165
     *
166
     * If the shortcut exists it will be completed, but a list of possible shortcuts is never returned for completion.
167
     *
168
     * @return array|false
169
     */
170
    protected function completeForOptionShortcuts()
171
    {
172
        $word = $this->context->getCurrentWord();
173
174
        if (strpos($word, '-') === 0 && strlen($word) == 2) {
175
            $definition = $this->command ? $this->command->getNativeDefinition() : $this->application->getDefinition();
176
177
            if ($definition->hasShortcut(substr($word, 1))) {
178
                return array($word);
179
            }
180
        }
181
182
        return false;
183
    }
184
185
    /**
186
     * Attempt to complete the current word as the value of an option shortcut
187
     *
188
     * @return array|false
189
     */
190 View Code Duplication
    protected function completeForOptionShortcutValues()
191
    {
192
        $wordIndex = $this->context->getWordIndex();
193
194
        if ($this->command && $wordIndex > 1) {
195
            $left = $this->context->getWordAtIndex($wordIndex - 1);
196
197
            // Complete short options
198
            if ($left[0] == '-' && strlen($left) == 2) {
199
                $shortcut = substr($left, 1);
200
                $def = $this->command->getNativeDefinition();
201
202
                if (!$def->hasShortcut($shortcut)) {
203
                    return false;
204
                }
205
206
                $opt = $def->getOptionForShortcut($shortcut);
207
                if ($opt->isValueRequired() || $opt->isValueOptional()) {
208
                    return $this->completeOption($opt);
209
                }
210
            }
211
        }
212
213
        return false;
214
    }
215
216
    /**
217
     * Attemp to complete the current word as the value of a long-form option
218
     *
219
     * @return array|false
220
     */
221 View Code Duplication
    protected function completeForOptionValues()
222
    {
223
        $wordIndex = $this->context->getWordIndex();
224
225
        if ($this->command && $wordIndex > 1) {
226
            $left = $this->context->getWordAtIndex($wordIndex - 1);
227
228
            if (strpos($left, '--') === 0) {
229
                $name = substr($left, 2);
230
                $def = $this->command->getNativeDefinition();
231
232
                if (!$def->hasOption($name)) {
233
                    return false;
234
                }
235
236
                $opt = $def->getOption($name);
237
                if ($opt->isValueRequired() || $opt->isValueOptional()) {
238
                    return $this->completeOption($opt);
239
                }
240
            }
241
        }
242
243
        return false;
244
    }
245
246
    /**
247
     * Attempt to complete the current word as a command name
248
     *
249
     * @return \Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionResultInterface|false
250
     */
251
    protected function completeForCommandName()
252
    {
253
        if (!$this->command || ($this->context->getWordIndex() == $this->commandWordIndex)) {
254
            return new CompletionResult($this->getCommands(), true);
255
        }
256
257
        return false;
258
    }
259
260
    /**
261
     * Attempt to complete the current word as a command argument value
262
     *
263
     * @return CompletionResultInterface|array|false
264
     * @see Symfony\Component\Console\Input\InputArgument
265
     */
266
    protected function completeForCommandArguments()
267
    {
268
        if (!$this->command || strpos($this->context->getCurrentWord(), '-') === 0) {
269
            return false;
270
        }
271
272
        $definition = $this->command->getNativeDefinition();
273
        $argWords = $this->mapArgumentsToWords($definition->getArguments());
274
        $wordIndex = $this->context->getWordIndex();
275
276
        if (isset($argWords[$wordIndex])) {
277
            $name = $argWords[$wordIndex];
278
        } elseif (!empty($argWords) && $definition->getArgument(end($argWords))->isArray()) {
279
            $name = end($argWords);
280
        } else {
281
            return false;
282
        }
283
284
        if ($helper = $this->getCompletionHelper($name, Completion::TYPE_ARGUMENT)) {
285
            return $helper->run();
286
        }
287
288
        if ($this->command instanceof CompletionAwareInterface) {
289
            return $this->command->completeArgumentValues($name, $this->context);
290
        }
291
292
        return false;
293
    }
294
295
    /**
296
     * Find a CompletionInterface that matches the current command, target name, and target type
297
     *
298
     * @param string $name
299
     * @param string $type
300
     * @return CompletionInterface|null
301
     */
302
    protected function getCompletionHelper($name, $type)
303
    {
304
        foreach ($this->helpers as $helper) {
305
            if ($helper->getType() != $type && $helper->getType() != CompletionInterface::ALL_TYPES) {
306
                continue;
307
            }
308
309
            if ($helper->getCommandName() == CompletionInterface::ALL_COMMANDS || $helper->getCommandName() == $this->command->getName()) {
310
                if ($helper->getTargetName() == $name) {
311
                    return $helper;
312
                }
313
            }
314
        }
315
316
        return null;
317
    }
318
319
    /**
320
     * Complete the value for the given option if a value completion is availble
321
     *
322
     * @param InputOption $option
323
     * @return array|false
324
     */
325
    protected function completeOption(InputOption $option)
326
    {
327
        if ($helper = $this->getCompletionHelper($option->getName(), Completion::TYPE_OPTION)) {
328
            return $helper->run();
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $helper->run(); (Stecman\Component\Symfon...mpletionResultInterface) is incompatible with the return type documented by Stecman\Component\Symfon...Handler::completeOption of type array|false.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
329
        }
330
331
        if ($this->command instanceof CompletionAwareInterface) {
332
            return $this->command->completeOptionValues($option->getName(), $this->context);
333
        }
334
335
        return false;
336
    }
337
338
    /**
339
     * Step through the command line to determine which word positions represent which argument values
340
     *
341
     * The word indexes of argument values are found by eliminating words that are known to not be arguments (options,
342
     * option values, and command names). Any word that doesn't match for elimination is assumed to be an argument
343
     * value,
344
     *
345
     * @param InputArgument[] $argumentDefinitions
346
     * @return array as [argument name => word index on command line]
347
     */
348
    protected function mapArgumentsToWords($argumentDefinitions)
349
    {
350
        $argumentPositions = array();
351
        $argumentNumber = 0;
352
        $previousWord = null;
353
        $argumentNames = array_keys($argumentDefinitions);
354
355
        // Build a list of option values to filter out
356
        $optionsWithArgs = $this->getOptionWordsWithValues();
357
358
        foreach ($this->context->getWords() as $wordIndex => $word) {
359
            // Skip program name, command name, options, and option values
360
            if ($wordIndex == 0
361
                || $wordIndex === $this->commandWordIndex
362
                || ($word && '-' === $word[0])
363
                || in_array($previousWord, $optionsWithArgs)) {
364
                $previousWord = $word;
365
                continue;
366
            } else {
367
                $previousWord = $word;
368
            }
369
370
            // If argument n exists, pair that argument's name with the current word
371
            if (isset($argumentNames[$argumentNumber])) {
372
                $argumentPositions[$wordIndex] = $argumentNames[$argumentNumber];
373
            }
374
375
            $argumentNumber++;
376
        }
377
378
        return $argumentPositions;
379
    }
380
381
    /**
382
     * Build a list of option words/flags that will have a value after them
383
     * Options are returned in the format they appear as on the command line.
384
     *
385
     * @return string[] - eg. ['--myoption', '-m', ... ]
386
     */
387
    protected function getOptionWordsWithValues()
388
    {
389
        $strings = array();
390
391
        foreach ($this->getAllOptions() as $option) {
392
            if ($option->isValueRequired()) {
393
                $strings[] = '--' . $option->getName();
394
395
                if ($option->getShortcut()) {
396
                    $strings[] = '-' . $option->getShortcut();
397
                }
398
            }
399
        }
400
401
        return $strings;
402
    }
403
404
    /**
405
     * Filter out results that don't match the current word on the command line
406
     *
407
     * @param CompletionResultInterface $result
408
     * @return CompletionResultInterface
409
     */
410
    protected function filterResult($result)
411
    {
412
        $curWord = $this->context->getCurrentWord();
413
414
        $values = $result->getValues();
415
        $desc = $result->isDescriptive();
416
417
        $values = array_filter($values, function ($val) use ($curWord) {
418
            return fnmatch($curWord . '*', $val);
419
        }, $result->isDescriptive() ? ARRAY_FILTER_USE_KEY : 0);
420
421
        return new CompletionResult($values, $desc);
422
    }
423
424
    /**
425
     * Get the combined options of the application and entered command
426
     *
427
     * @return InputOption[]
428
     */
429
    protected function getAllOptions()
430
    {
431
        if (!$this->command) {
432
            return $this->application->getDefinition()->getOptions();
433
        }
434
435
        return array_merge(
436
            $this->command->getNativeDefinition()->getOptions(),
437
            $this->application->getDefinition()->getOptions()
438
        );
439
    }
440
441
    /**
442
     * Get command names available for completion
443
     *
444
     * Filters out hidden commands where supported.
445
     *
446
     * @return string[]
447
     */
448
    protected function getCommands()
449
    {
450
        // Command::Hidden isn't supported before Symfony Console 3.2.0
451
        // We don't complete hidden command names as these are intended to be private
452
        if (method_exists('\Symfony\Component\Console\Command\Command', 'isHidden')) {
453
            $commands = array();
454
455
            foreach ($this->application->all() as $name => $command) {
456
                if (!$command->isHidden()) {
457
                    $commands[$name] = $command->getDescription();
458
                }
459
            }
460
461
            return $commands;
462
463
        } else {
464
465
            // Fallback for compatibility with Symfony Console < 3.2.0
466
            // This was the behaviour prior to pull #75
467
            $commands = $this->application->all();
468
            unset($commands['_completion']);
469
470
            return array_keys($commands);
471
        }
472
    }
473
474
    /**
475
     * Find the current command name in the command-line
476
     *
477
     * Note this only cares about flag-type options. Options with values cannot
478
     * appear before a command name in Symfony Console application.
479
     *
480
     * @return Command|null
481
     */
482
    private function detectCommand()
483
    {
484
        // Always skip the first word (program name)
485
        $skipNext = true;
486
487
        foreach ($this->context->getWords() as $index => $word) {
0 ignored issues
show
Bug introduced by
The expression $this->context->getWords() of type null|array<integer,string> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
488
489
            // Skip word if flagged
490
            if ($skipNext) {
491
                $skipNext = false;
492
                continue;
493
            }
494
495
            // Skip empty words and words that look like options
496
            if (strlen($word) == 0 || $word[0] === '-') {
497
                continue;
498
            }
499
500
            // Return the first unambiguous match to argument-like words
501
            try {
502
                $cmd = $this->application->find($word);
503
                $this->commandWordIndex = $index;
0 ignored issues
show
Documentation Bug introduced by
It seems like $index can also be of type string. However, the property $commandWordIndex is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
504
                return $cmd;
505
            } catch (\InvalidArgumentException $e) {
506
                // Exception thrown, when multiple or no commands are found.
507
            }
508
        }
509
510
        // No command found
511
        return null;
512
    }
513
}
514