Completed
Push — master ( 902f29...8b1d65 )
by Stephen
02:21 queued 30s
created

CompletionHandler::__construct()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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