CompletionHandler   F
last analyzed

Complexity

Total Complexity 79

Size/Duplication

Total Lines 505
Duplicated Lines 9.7 %

Coupling/Cohesion

Components 1
Dependencies 9

Importance

Changes 0
Metric Value
wmc 79
lcom 1
cbo 9
dl 49
loc 505
rs 2.08
c 0
b 0
f 0

21 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 24 1
A setContext() 0 4 1
A getContext() 0 4 1
A addHandlers() 0 4 1
A addHandler() 0 4 1
A runCompletion() 0 29 4
A getInput() 0 9 1
A completeForOptions() 0 16 3
A completeForOptionShortcuts() 0 14 5
B completeForOptionShortcutValues() 25 25 8
B completeForOptionValues() 24 24 7
A completeForCommandName() 0 8 3
B completeForCommandArguments() 0 28 8
B getCompletionHelper() 0 16 7
A completeOption() 0 12 3
B mapArgumentsToWords() 0 32 8
A getOptionWordsWithValues() 0 16 4
A filterResults() 0 8 1
A getAllOptions() 0 11 2
A getCommandNames() 0 25 4
B detectCommand() 0 31 6

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like CompletionHandler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CompletionHandler, and based on these observations, apply Extract Interface, too.

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 Symfony\Component\Console\Application;
8
use Symfony\Component\Console\Command\Command;
9
use Symfony\Component\Console\Input\ArrayInput;
10
use Symfony\Component\Console\Input\InputArgument;
11
use Symfony\Component\Console\Input\InputOption;
12
13
class CompletionHandler
14
{
15
    /**
16
     * Application to complete for
17
     * @var \Symfony\Component\Console\Application
18
     */
19
    protected $application;
20
21
    /**
22
     * @var Command
23
     */
24
    protected $command;
25
26
    /**
27
     * @var CompletionContext
28
     */
29
    protected $context;
30
31
    /**
32
     * Array of completion helpers.
33
     * @var CompletionInterface[]
34
     */
35
    protected $helpers = array();
36
37
    /**
38
     * Index the command name was detected at
39
     * @var int
40
     */
41
    private $commandWordIndex;
42
43
    public function __construct(Application $application, CompletionContext $context = null)
44
    {
45
        $this->application = $application;
46
        $this->context = $context;
47
48
        // Set up completions for commands that are built-into Application
49
        $this->addHandler(
50
            new Completion(
51
                'help',
52
                'command_name',
53
                Completion::TYPE_ARGUMENT,
54
                $this->getCommandNames()
55
            )
56
        );
57
58
        $this->addHandler(
59
            new Completion(
60
                'list',
61
                'namespace',
62
                Completion::TYPE_ARGUMENT,
63
                $application->getNamespaces()
64
            )
65
        );
66
    }
67
68
    public function setContext(CompletionContext $context)
69
    {
70
        $this->context = $context;
71
    }
72
73
    /**
74
     * @return CompletionContext
75
     */
76
    public function getContext()
77
    {
78
        return $this->context;
79
    }
80
81
    /**
82
     * @param CompletionInterface[] $array
83
     */
84
    public function addHandlers(array $array)
85
    {
86
        $this->helpers = array_merge($this->helpers, $array);
87
    }
88
89
    /**
90
     * @param CompletionInterface $helper
91
     */
92
    public function addHandler(CompletionInterface $helper)
93
    {
94
        $this->helpers[] = $helper;
95
    }
96
97
    /**
98
     * Do the actual completion, returning an array of strings to provide to the parent shell's completion system
99
     *
100
     * @throws \RuntimeException
101
     * @return string[]
102
     */
103
    public function runCompletion()
104
    {
105
        if (!$this->context) {
106
            throw new \RuntimeException('A CompletionContext must be set before requesting completion.');
107
        }
108
109
        // Set the command to query options and arugments from
110
        $this->command = $this->detectCommand();
111
112
        $process = array(
113
            'completeForOptionValues',
114
            'completeForOptionShortcuts',
115
            'completeForOptionShortcutValues',
116
            'completeForOptions',
117
            'completeForCommandName',
118
            'completeForCommandArguments'
119
        );
120
121
        foreach ($process as $methodName) {
122
            $result = $this->{$methodName}();
123
124
            if (false !== $result) {
125
                // Return the result of the first completion mode that matches
126
                return $this->filterResults((array) $result);
127
            }
128
        }
129
130
        return array();
131
    }
132
133
    /**
134
     * Get an InputInterface representation of the completion context
135
     *
136
     * @deprecated Incorrectly uses the ArrayInput API and is no longer needed.
137
     *             This will be removed in the next major version.
138
     *
139
     * @return ArrayInput
140
     */
141
    public function getInput()
142
    {
143
        // Filter the command line content to suit ArrayInput
144
        $words = $this->context->getWords();
145
        array_shift($words);
146
        $words = array_filter($words);
147
148
        return new ArrayInput($words);
149
    }
150
151
    /**
152
     * Attempt to complete the current word as a long-form option (--my-option)
153
     *
154
     * @return array|false
155
     */
156
    protected function completeForOptions()
157
    {
158
        $word = $this->context->getCurrentWord();
159
160
        if (substr($word, 0, 2) === '--') {
161
            $options = array();
162
163
            foreach ($this->getAllOptions() as $opt) {
164
                $options[] = '--'.$opt->getName();
165
            }
166
167
            return $options;
168
        }
169
170
        return false;
171
    }
172
173
    /**
174
     * Attempt to complete the current word as an option shortcut.
175
     *
176
     * If the shortcut exists it will be completed, but a list of possible shortcuts is never returned for completion.
177
     *
178
     * @return array|false
179
     */
180
    protected function completeForOptionShortcuts()
181
    {
182
        $word = $this->context->getCurrentWord();
183
184
        if (strpos($word, '-') === 0 && strlen($word) == 2) {
185
            $definition = $this->command ? $this->command->getNativeDefinition() : $this->application->getDefinition();
186
187
            if ($definition->hasShortcut(substr($word, 1))) {
188
                return array($word);
189
            }
190
        }
191
192
        return false;
193
    }
194
195
    /**
196
     * Attempt to complete the current word as the value of an option shortcut
197
     *
198
     * @return array|false
199
     */
200 View Code Duplication
    protected function completeForOptionShortcutValues()
201
    {
202
        $wordIndex = $this->context->getWordIndex();
203
204
        if ($this->command && $wordIndex > 1) {
205
            $left = $this->context->getWordAtIndex($wordIndex - 1);
206
207
            // Complete short options
208
            if ($left[0] == '-' && strlen($left) == 2) {
209
                $shortcut = substr($left, 1);
210
                $def = $this->command->getNativeDefinition();
211
212
                if (!$def->hasShortcut($shortcut)) {
213
                    return false;
214
                }
215
216
                $opt = $def->getOptionForShortcut($shortcut);
217
                if ($opt->isValueRequired() || $opt->isValueOptional()) {
218
                    return $this->completeOption($opt);
219
                }
220
            }
221
        }
222
223
        return false;
224
    }
225
226
    /**
227
     * Attemp to complete the current word as the value of a long-form option
228
     *
229
     * @return array|false
230
     */
231 View Code Duplication
    protected function completeForOptionValues()
232
    {
233
        $wordIndex = $this->context->getWordIndex();
234
235
        if ($this->command && $wordIndex > 1) {
236
            $left = $this->context->getWordAtIndex($wordIndex - 1);
237
238
            if (strpos($left, '--') === 0) {
239
                $name = substr($left, 2);
240
                $def = $this->command->getNativeDefinition();
241
242
                if (!$def->hasOption($name)) {
243
                    return false;
244
                }
245
246
                $opt = $def->getOption($name);
247
                if ($opt->isValueRequired() || $opt->isValueOptional()) {
248
                    return $this->completeOption($opt);
249
                }
250
            }
251
        }
252
253
        return false;
254
    }
255
256
    /**
257
     * Attempt to complete the current word as a command name
258
     *
259
     * @return array|false
260
     */
261
    protected function completeForCommandName()
262
    {
263
        if (!$this->command || $this->context->getWordIndex() == $this->commandWordIndex) {
264
            return $this->getCommandNames();
265
        }
266
267
        return false;
268
    }
269
270
    /**
271
     * Attempt to complete the current word as a command argument value
272
     *
273
     * @see Symfony\Component\Console\Input\InputArgument
274
     * @return array|false
275
     */
276
    protected function completeForCommandArguments()
277
    {
278
        if (!$this->command || strpos($this->context->getCurrentWord(), '-') === 0) {
279
            return false;
280
        }
281
282
        $definition = $this->command->getNativeDefinition();
283
        $argWords = $this->mapArgumentsToWords($definition->getArguments());
284
        $wordIndex = $this->context->getWordIndex();
285
286
        if (isset($argWords[$wordIndex])) {
287
            $name = $argWords[$wordIndex];
288
        } elseif (!empty($argWords) && $definition->getArgument(end($argWords))->isArray()) {
289
            $name = end($argWords);
290
        } else {
291
            return false;
292
        }
293
294
        if ($helper = $this->getCompletionHelper($name, Completion::TYPE_ARGUMENT)) {
295
            return $helper->run();
296
        }
297
298
        if ($this->command instanceof CompletionAwareInterface) {
299
            return $this->command->completeArgumentValues($name, $this->context);
300
        }
301
302
        return false;
303
    }
304
305
    /**
306
     * Find a CompletionInterface that matches the current command, target name, and target type
307
     *
308
     * @param string $name
309
     * @param string $type
310
     * @return CompletionInterface|null
311
     */
312
    protected function getCompletionHelper($name, $type)
313
    {
314
        foreach ($this->helpers as $helper) {
315
            if ($helper->getType() != $type && $helper->getType() != CompletionInterface::ALL_TYPES) {
316
                continue;
317
            }
318
319
            if ($helper->getCommandName() == CompletionInterface::ALL_COMMANDS || $helper->getCommandName() == $this->command->getName()) {
320
                if ($helper->getTargetName() == $name) {
321
                    return $helper;
322
                }
323
            }
324
        }
325
326
        return null;
327
    }
328
329
    /**
330
     * Complete the value for the given option if a value completion is availble
331
     *
332
     * @param InputOption $option
333
     * @return array|false
334
     */
335
    protected function completeOption(InputOption $option)
336
    {
337
        if ($helper = $this->getCompletionHelper($option->getName(), Completion::TYPE_OPTION)) {
338
            return $helper->run();
339
        }
340
341
        if ($this->command instanceof CompletionAwareInterface) {
342
            return $this->command->completeOptionValues($option->getName(), $this->context);
343
        }
344
345
        return false;
346
    }
347
348
    /**
349
     * Step through the command line to determine which word positions represent which argument values
350
     *
351
     * The word indexes of argument values are found by eliminating words that are known to not be arguments (options,
352
     * option values, and command names). Any word that doesn't match for elimination is assumed to be an argument value,
353
     *
354
     * @param InputArgument[] $argumentDefinitions
355
     * @return array as [argument name => word index on command line]
356
     */
357
    protected function mapArgumentsToWords($argumentDefinitions)
358
    {
359
        $argumentPositions = array();
360
        $argumentNumber = 0;
361
        $previousWord = null;
362
        $argumentNames = array_keys($argumentDefinitions);
363
364
        // Build a list of option values to filter out
365
        $optionsWithArgs = $this->getOptionWordsWithValues();
366
367
        foreach ($this->context->getWords() as $wordIndex => $word) {
368
            // Skip program name, command name, options, and option values
369
            if ($wordIndex == 0
370
                || $wordIndex === $this->commandWordIndex
371
                || ($word && '-' === $word[0])
372
                || in_array($previousWord, $optionsWithArgs)) {
373
                $previousWord = $word;
374
                continue;
375
            } else {
376
                $previousWord = $word;
377
            }
378
379
            // If argument n exists, pair that argument's name with the current word
380
            if (isset($argumentNames[$argumentNumber])) {
381
                $argumentPositions[$wordIndex] = $argumentNames[$argumentNumber];
382
            }
383
384
            $argumentNumber++;
385
        }
386
387
        return $argumentPositions;
388
    }
389
390
    /**
391
     * Build a list of option words/flags that will have a value after them
392
     * Options are returned in the format they appear as on the command line.
393
     *
394
     * @return string[] - eg. ['--myoption', '-m', ... ]
395
     */
396
    protected function getOptionWordsWithValues()
397
    {
398
        $strings = array();
399
400
        foreach ($this->getAllOptions() as $option) {
401
            if ($option->isValueRequired()) {
402
                $strings[] = '--' . $option->getName();
403
404
                if ($option->getShortcut()) {
405
                    $strings[] = '-' . $option->getShortcut();
406
                }
407
            }
408
        }
409
410
        return $strings;
411
    }
412
413
    /**
414
     * Filter out results that don't match the current word on the command line
415
     *
416
     * @param string[] $array
417
     * @return string[]
418
     */
419
    protected function filterResults(array $array)
420
    {
421
        $curWord = $this->context->getCurrentWord();
422
423
        return array_filter($array, function($val) use ($curWord) {
424
            return fnmatch($curWord.'*', $val);
425
        });
426
    }
427
428
    /**
429
     * Get the combined options of the application and entered command
430
     *
431
     * @return InputOption[]
432
     */
433
    protected function getAllOptions()
434
    {
435
        if (!$this->command) {
436
            return $this->application->getDefinition()->getOptions();
437
        }
438
439
        return array_merge(
440
            $this->command->getNativeDefinition()->getOptions(),
441
            $this->application->getDefinition()->getOptions()
442
        );
443
    }
444
445
    /**
446
     * Get command names available for completion
447
     *
448
     * Filters out hidden commands where supported.
449
     *
450
     * @return string[]
451
     */
452
    protected function getCommandNames()
453
    {
454
        // Command::Hidden isn't supported before Symfony Console 3.2.0
455
        // We don't complete hidden command names as these are intended to be private
456
        if (method_exists('\Symfony\Component\Console\Command\Command', 'isHidden')) {
457
            $commands = array();
458
459
            foreach ($this->application->all() as $name => $command) {
460
                if (!$command->isHidden()) {
461
                    $commands[] = $name;
462
                }
463
            }
464
465
            return $commands;
466
467
        } else {
468
469
            // Fallback for compatibility with Symfony Console < 3.2.0
470
            // This was the behaviour prior to pull #75
471
            $commands = $this->application->all();
472
            unset($commands['_completion']);
473
474
            return array_keys($commands);
475
        }
476
    }
477
478
    /**
479
     * Find the current command name in the command-line
480
     *
481
     * Note this only cares about flag-type options. Options with values cannot
482
     * appear before a command name in Symfony Console application.
483
     *
484
     * @return Command|null
485
     */
486
    private function detectCommand()
487
    {
488
        // Always skip the first word (program name)
489
        $skipNext = true;
490
491
        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...
492
493
            // Skip word if flagged
494
            if ($skipNext) {
495
                $skipNext = false;
496
                continue;
497
            }
498
499
            // Skip empty words and words that look like options
500
            if (strlen($word) == 0 || $word[0] === '-') {
501
                continue;
502
            }
503
504
            // Return the first unambiguous match to argument-like words
505
            try {
506
                $cmd = $this->application->find($word);
507
                $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...
508
                return $cmd;
509
            } catch (\InvalidArgumentException $e) {
510
                // Exception thrown, when multiple or no commands are found.
511
            }
512
        }
513
514
        // No command found
515
        return null;
516
    }
517
}
518