3 namespace Stecman\Component\Symfony\Console\BashCompletion;
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;
13 class CompletionHandler
16 * Application to complete for
17 * @var \Symfony\Component\Console\Application
19 protected $application;
27 * @var CompletionContext
32 * Array of completion helpers.
33 * @var CompletionInterface[]
35 protected $helpers = array();
37 public function __construct(Application $application, CompletionContext $context = null)
39 $this->application = $application;
40 $this->context = $context;
42 // Set up completions for commands that are built-into Application
47 Completion::TYPE_ARGUMENT,
48 $this->getCommandNames()
56 Completion::TYPE_ARGUMENT,
57 $application->getNamespaces()
62 public function setContext(CompletionContext $context)
64 $this->context = $context;
68 * @return CompletionContext
70 public function getContext()
72 return $this->context;
76 * @param CompletionInterface[] $array
78 public function addHandlers(array $array)
80 $this->helpers = array_merge($this->helpers, $array);
84 * @param CompletionInterface $helper
86 public function addHandler(CompletionInterface $helper)
88 $this->helpers[] = $helper;
92 * Do the actual completion, returning an array of strings to provide to the parent shell's completion system
94 * @throws \RuntimeException
97 public function runCompletion()
99 if (!$this->context) {
100 throw new \RuntimeException('A CompletionContext must be set before requesting completion.');
103 $cmdName = $this->getInput()->getFirstArgument();
106 $this->command = $this->application->find($cmdName);
107 } catch (\InvalidArgumentException $e) {
108 // Exception thrown, when multiple or none commands are found.
112 'completeForOptionValues',
113 'completeForOptionShortcuts',
114 'completeForOptionShortcutValues',
115 'completeForOptions',
116 'completeForCommandName',
117 'completeForCommandArguments'
120 foreach ($process as $methodName) {
121 $result = $this->{$methodName}();
123 if (false !== $result) {
124 // Return the result of the first completion mode that matches
125 return $this->filterResults((array) $result);
133 * Get an InputInterface representation of the completion context
137 public function getInput()
139 // Filter the command line content to suit ArrayInput
140 $words = $this->context->getWords();
142 $words = array_filter($words);
144 return new ArrayInput($words);
148 * Attempt to complete the current word as a long-form option (--my-option)
150 * @return array|false
152 protected function completeForOptions()
154 $word = $this->context->getCurrentWord();
156 if (substr($word, 0, 2) === '--') {
159 foreach ($this->getAllOptions() as $opt) {
160 $options[] = '--'.$opt->getName();
170 * Attempt to complete the current word as an option shortcut.
172 * If the shortcut exists it will be completed, but a list of possible shortcuts is never returned for completion.
174 * @return array|false
176 protected function completeForOptionShortcuts()
178 $word = $this->context->getCurrentWord();
180 if (strpos($word, '-') === 0 && strlen($word) == 2) {
181 $definition = $this->command ? $this->command->getNativeDefinition() : $this->application->getDefinition();
183 if ($definition->hasShortcut(substr($word, 1))) {
192 * Attempt to complete the current word as the value of an option shortcut
194 * @return array|false
196 protected function completeForOptionShortcutValues()
198 $wordIndex = $this->context->getWordIndex();
200 if ($this->command && $wordIndex > 1) {
201 $left = $this->context->getWordAtIndex($wordIndex - 1);
203 // Complete short options
204 if ($left[0] == '-' && strlen($left) == 2) {
205 $shortcut = substr($left, 1);
206 $def = $this->command->getNativeDefinition();
208 if (!$def->hasShortcut($shortcut)) {
212 $opt = $def->getOptionForShortcut($shortcut);
213 if ($opt->isValueRequired() || $opt->isValueOptional()) {
214 return $this->completeOption($opt);
223 * Attemp to complete the current word as the value of a long-form option
225 * @return array|false
227 protected function completeForOptionValues()
229 $wordIndex = $this->context->getWordIndex();
231 if ($this->command && $wordIndex > 1) {
232 $left = $this->context->getWordAtIndex($wordIndex - 1);
234 if (strpos($left, '--') === 0) {
235 $name = substr($left, 2);
236 $def = $this->command->getNativeDefinition();
238 if (!$def->hasOption($name)) {
242 $opt = $def->getOption($name);
243 if ($opt->isValueRequired() || $opt->isValueOptional()) {
244 return $this->completeOption($opt);
253 * Attempt to complete the current word as a command name
255 * @return array|false
257 protected function completeForCommandName()
259 if (!$this->command || (count($this->context->getWords()) == 2 && $this->context->getWordIndex() == 1)) {
260 return $this->getCommandNames();
267 * Attempt to complete the current word as a command argument value
269 * @see Symfony\Component\Console\Input\InputArgument
270 * @return array|false
272 protected function completeForCommandArguments()
274 if (!$this->command || strpos($this->context->getCurrentWord(), '-') === 0) {
278 $definition = $this->command->getNativeDefinition();
279 $argWords = $this->mapArgumentsToWords($definition->getArguments());
280 $wordIndex = $this->context->getWordIndex();
282 if (isset($argWords[$wordIndex])) {
283 $name = $argWords[$wordIndex];
284 } elseif (!empty($argWords) && $definition->getArgument(end($argWords))->isArray()) {
285 $name = end($argWords);
290 if ($helper = $this->getCompletionHelper($name, Completion::TYPE_ARGUMENT)) {
291 return $helper->run();
294 if ($this->command instanceof CompletionAwareInterface) {
295 return $this->command->completeArgumentValues($name, $this->context);
302 * Find a CompletionInterface that matches the current command, target name, and target type
304 * @param string $name
305 * @param string $type
306 * @return CompletionInterface|null
308 protected function getCompletionHelper($name, $type)
310 foreach ($this->helpers as $helper) {
311 if ($helper->getType() != $type && $helper->getType() != CompletionInterface::ALL_TYPES) {
315 if ($helper->getCommandName() == CompletionInterface::ALL_COMMANDS || $helper->getCommandName() == $this->command->getName()) {
316 if ($helper->getTargetName() == $name) {
326 * Complete the value for the given option if a value completion is availble
328 * @param InputOption $option
329 * @return array|false
331 protected function completeOption(InputOption $option)
333 if ($helper = $this->getCompletionHelper($option->getName(), Completion::TYPE_OPTION)) {
334 return $helper->run();
337 if ($this->command instanceof CompletionAwareInterface) {
338 return $this->command->completeOptionValues($option->getName(), $this->context);
345 * Step through the command line to determine which word positions represent which argument values
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,
350 * @param InputArgument[] $argumentDefinitions
351 * @return array as [argument name => word index on command line]
353 protected function mapArgumentsToWords($argumentDefinitions)
355 $argumentPositions = array();
357 $previousWord = null;
358 $argumentNames = array_keys($argumentDefinitions);
360 // Build a list of option values to filter out
361 $optionsWithArgs = $this->getOptionWordsWithValues();
363 foreach ($this->context->getWords() as $wordIndex => $word) {
364 // Skip program name, command name, options, and option values
366 || ($word && '-' === $word[0])
367 || in_array($previousWord, $optionsWithArgs)) {
368 $previousWord = $word;
371 $previousWord = $word;
374 // If argument n exists, pair that argument's name with the current word
375 if (isset($argumentNames[$argumentNumber])) {
376 $argumentPositions[$wordIndex] = $argumentNames[$argumentNumber];
382 return $argumentPositions;
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.
389 * @return string[] - eg. ['--myoption', '-m', ... ]
391 protected function getOptionWordsWithValues()
395 foreach ($this->getAllOptions() as $option) {
396 if ($option->isValueRequired()) {
397 $strings[] = '--' . $option->getName();
399 if ($option->getShortcut()) {
400 $strings[] = '-' . $option->getShortcut();
409 * Filter out results that don't match the current word on the command line
411 * @param string[] $array
414 protected function filterResults(array $array)
416 $curWord = $this->context->getCurrentWord();
418 return array_filter($array, function($val) use ($curWord) {
419 return fnmatch($curWord.'*', $val);
424 * Get the combined options of the application and entered command
426 * @return InputOption[]
428 protected function getAllOptions()
430 if (!$this->command) {
431 return $this->application->getDefinition()->getOptions();
435 $this->command->getNativeDefinition()->getOptions(),
436 $this->application->getDefinition()->getOptions()
441 * Get command names available for completion
443 * Filters out hidden commands where supported.
447 protected function getCommandNames()
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')) {
454 foreach ($this->application->all() as $name => $command) {
455 if (!$command->isHidden()) {
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']);
469 return array_keys($commands);