Completed
Pull Request — master (#75)
by
unknown
02:13
created

CompletionHandler::getCommandNames()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 24
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 24
rs 8.5125
cc 6
eloc 13
nc 6
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 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
     * @var bool
39
     */
40
    protected $commandsHidable;
41
42
    public function __construct(Application $application, CompletionContext $context = null)
43
    {
44
        $this->application = $application;
45
        $this->context = $context;
46
        $this->commandsHidable = method_exists(Command::class, 'isHidden');
47
48
        $this->addHandler(
49
            new Completion(
50
                'help',
51
                'command_name',
52
                Completion::TYPE_ARGUMENT,
53
                $this->getCommandNames()
54
            )
55
        );
56
57
        $this->addHandler(
58
            new Completion(
59
                'list',
60
                'namespace',
61
                Completion::TYPE_ARGUMENT,
62
                $application->getNamespaces()
63
            )
64
        );
65
    }
66
67
    public function setContext(CompletionContext $context)
68
    {
69
        $this->context = $context;
70
    }
71
72
    /**
73
     * @return CompletionContext
74
     */
75
    public function getContext()
76
    {
77
        return $this->context;
78
    }
79
80
    /**
81
     * @param CompletionInterface[] $array
82
     */
83
    public function addHandlers(array $array)
84
    {
85
        $this->helpers = array_merge($this->helpers, $array);
86
    }
87
88
    /**
89
     * @param CompletionInterface $helper
90
     */
91
    public function addHandler(CompletionInterface $helper)
92
    {
93
        $this->helpers[] = $helper;
94
    }
95
96
    /**
97
     * Do the actual completion, returning an array of strings to provide to the parent shell's completion system
98
     *
99
     * @throws \RuntimeException
100
     * @return string[]
101
     */
102
    public function runCompletion()
103
    {
104
        if (!$this->context) {
105
            throw new \RuntimeException('A CompletionContext must be set before requesting completion.');
106
        }
107
108
        $cmdName = $this->getInput()->getFirstArgument();
109
110
        try {
111
            $this->command = $this->application->find($cmdName);
112
        } catch (\InvalidArgumentException $e) {
113
            // Exception thrown, when multiple or none commands are found.
114
        }
115
116
        $process = array(
117
            'completeForOptionValues',
118
            'completeForOptionShortcuts',
119
            'completeForOptionShortcutValues',
120
            'completeForOptions',
121
            'completeForCommandName',
122
            'completeForCommandArguments'
123
        );
124
125
        foreach ($process as $methodName) {
126
            $result = $this->{$methodName}();
127
128
            if (false !== $result) {
129
                // Return the result of the first completion mode that matches
130
                return $this->filterResults((array) $result);
131
            }
132
        }
133
134
        return array();
135
    }
136
137
    /**
138
     * Get an InputInterface representation of the completion context
139
     *
140
     * @return ArrayInput
141
     */
142
    public function getInput()
143
    {
144
        // Filter the command line content to suit ArrayInput
145
        $words = $this->context->getWords();
146
        array_shift($words);
147
        $words = array_filter($words);
148
149
        return new ArrayInput($words);
150
    }
151
152
    /**
153
     * Attempt to complete the current word as a long-form option (--my-option)
154
     *
155
     * @return array|false
156
     */
157
    protected function completeForOptions()
158
    {
159
        $word = $this->context->getCurrentWord();
160
161
        if (substr($word, 0, 2) === '--') {
162
            $options = array();
163
164
            foreach ($this->getAllOptions() as $opt) {
165
                $options[] = '--'.$opt->getName();
166
            }
167
168
            return $options;
169
        }
170
171
        return false;
172
    }
173
174
    /**
175
     * Attempt to complete the current word as an option shortcut.
176
     *
177
     * If the shortcut exists it will be completed, but a list of possible shortcuts is never returned for completion.
178
     *
179
     * @return array|false
180
     */
181
    protected function completeForOptionShortcuts()
182
    {
183
        $word = $this->context->getCurrentWord();
184
185
        if (strpos($word, '-') === 0 && strlen($word) == 2) {
186
            $definition = $this->command ? $this->command->getNativeDefinition() : $this->application->getDefinition();
187
188
            if ($definition->hasShortcut(substr($word, 1))) {
189
                return array($word);
190
            }
191
        }
192
193
        return false;
194
    }
195
196
    /**
197
     * Attempt to complete the current word as the value of an option shortcut
198
     *
199
     * @return array|false
200
     */
201 View Code Duplication
    protected function completeForOptionShortcutValues()
202
    {
203
        $wordIndex = $this->context->getWordIndex();
204
205
        if ($this->command && $wordIndex > 1) {
206
            $left = $this->context->getWordAtIndex($wordIndex - 1);
207
208
            // Complete short options
209
            if ($left[0] == '-' && strlen($left) == 2) {
210
                $shortcut = substr($left, 1);
211
                $def = $this->command->getNativeDefinition();
212
213
                if (!$def->hasShortcut($shortcut)) {
214
                    return false;
215
                }
216
217
                $opt = $def->getOptionForShortcut($shortcut);
218
                if ($opt->isValueRequired() || $opt->isValueOptional()) {
219
                    return $this->completeOption($opt);
220
                }
221
            }
222
        }
223
224
        return false;
225
    }
226
227
    /**
228
     * Attemp to complete the current word as the value of a long-form option
229
     *
230
     * @return array|false
231
     */
232 View Code Duplication
    protected function completeForOptionValues()
233
    {
234
        $wordIndex = $this->context->getWordIndex();
235
236
        if ($this->command && $wordIndex > 1) {
237
            $left = $this->context->getWordAtIndex($wordIndex - 1);
238
239
            if (strpos($left, '--') === 0) {
240
                $name = substr($left, 2);
241
                $def = $this->command->getNativeDefinition();
242
243
                if (!$def->hasOption($name)) {
244
                    return false;
245
                }
246
247
                $opt = $def->getOption($name);
248
                if ($opt->isValueRequired() || $opt->isValueOptional()) {
249
                    return $this->completeOption($opt);
250
                }
251
            }
252
        }
253
254
        return false;
255
    }
256
257
    /**
258
     * Attempt to complete the current word as a command name
259
     *
260
     * @return array|false
261
     */
262
    protected function completeForCommandName()
263
    {
264
        if (!$this->command || (count($this->context->getWords()) == 2 && $this->context->getWordIndex() == 1)) {
265
            return $this->getCommandNames();
266
        }
267
268
        return false;
269
    }
270
271
    /**
272
     * Attempt to complete the current word as a command argument value
273
     *
274
     * @see Symfony\Component\Console\Input\InputArgument
275
     * @return array|false
276
     */
277
    protected function completeForCommandArguments()
278
    {
279
        if (!$this->command || strpos($this->context->getCurrentWord(), '-') === 0) {
280
            return false;
281
        }
282
283
        $definition = $this->command->getNativeDefinition();
284
        $argWords = $this->mapArgumentsToWords($definition->getArguments());
285
        $wordIndex = $this->context->getWordIndex();
286
287
        if (isset($argWords[$wordIndex])) {
288
            $name = $argWords[$wordIndex];
289
        } elseif (!empty($argWords) && $definition->getArgument(end($argWords))->isArray()) {
290
            $name = end($argWords);
291
        } else {
292
            return false;
293
        }
294
295
        if ($helper = $this->getCompletionHelper($name, Completion::TYPE_ARGUMENT)) {
296
            return $helper->run();
297
        }
298
299
        if ($this->command instanceof CompletionAwareInterface) {
300
            return $this->command->completeArgumentValues($name, $this->context);
301
        }
302
303
        return false;
304
    }
305
306
    /**
307
     * Find a CompletionInterface that matches the current command, target name, and target type
308
     *
309
     * @param string $name
310
     * @param string $type
311
     * @return CompletionInterface|null
312
     */
313
    protected function getCompletionHelper($name, $type)
314
    {
315
        foreach ($this->helpers as $helper) {
316
            if ($helper->getType() != $type && $helper->getType() != CompletionInterface::ALL_TYPES) {
317
                continue;
318
            }
319
320
            if ($helper->getCommandName() == CompletionInterface::ALL_COMMANDS || $helper->getCommandName() == $this->command->getName()) {
321
                if ($helper->getTargetName() == $name) {
322
                    return $helper;
323
                }
324
            }
325
        }
326
327
        return null;
328
    }
329
330
    /**
331
     * Complete the value for the given option if a value completion is availble
332
     *
333
     * @param InputOption $option
334
     * @return array|false
335
     */
336
    protected function completeOption(InputOption $option)
337
    {
338
        if ($helper = $this->getCompletionHelper($option->getName(), Completion::TYPE_OPTION)) {
339
            return $helper->run();
340
        }
341
342
        if ($this->command instanceof CompletionAwareInterface) {
343
            return $this->command->completeOptionValues($option->getName(), $this->context);
344
        }
345
346
        return false;
347
    }
348
349
    /**
350
     * Step through the command line to determine which word positions represent which argument values
351
     *
352
     * The word indexes of argument values are found by eliminating words that are known to not be arguments (options,
353
     * option values, and command names). Any word that doesn't match for elimination is assumed to be an argument value,
354
     *
355
     * @param InputArgument[] $argumentDefinitions
356
     * @return array as [argument name => word index on command line]
357
     */
358
    protected function mapArgumentsToWords($argumentDefinitions)
359
    {
360
        $argumentPositions = array();
361
        $argumentNumber = 0;
362
        $previousWord = null;
363
        $argumentNames = array_keys($argumentDefinitions);
364
365
        // Build a list of option values to filter out
366
        $optionsWithArgs = $this->getOptionWordsWithValues();
367
368
        foreach ($this->context->getWords() as $wordIndex => $word) {
369
            // Skip program name, command name, options, and option values
370
            if ($wordIndex < 2
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
    protected function getCommandNames()
446
    {
447
        $commands = array();
448
449
        if ($this->commandsHidable) {
450
            foreach ($this->application->all() as $name => $command) {
451
                if ($command->isHidden()) {
452
                    continue;
453
                }
454
455
                $commands[] = $name;
456
            }
457
        } else {
458
            foreach ($this->application->all() as $name => $command) {
459
                if ($name === '_completion') {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison === seems to always evaluate to false as the types of $name (integer) and '_completion' (string) can never be identical. Maybe you want to use a loose comparison == instead?
Loading history...
460
                    continue;
461
                }
462
463
                $commands[] = $name;
464
            }
465
        }
466
467
        return $commands;
468
    }
469
}
470