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(); |
|
|
|
|
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) { |
|
|
|
|
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; |
|
|
|
|
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
|
|
|
|
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:
Our function
my_function
expects aPost
object, and outputs the author of the post. The base classPost
returns a simple string and outputting a simple string will work just fine. However, the child classBlogPost
which is a sub-type ofPost
instead decided to return anobject
, and is therefore violating the SOLID principles. If aBlogPost
were passed tomy_function
, PHP would not complain, but ultimately fail when executing thestrtoupper
call in its body.