Completed
Push — master ( e10474...5461d4 )
by Stephen
02:06
created

CompletionHandler::completeForCommandArguments()   C

Complexity

Conditions 8
Paths 8

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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