diff options
author | Joas Schilling <coding@schilljs.com> | 2016-09-21 01:03:15 +0300 |
---|---|---|
committer | Joas Schilling <coding@schilljs.com> | 2016-09-21 01:03:15 +0300 |
commit | b6ef8a42ae8c7d4d69c6acdf919072a7989648d7 (patch) | |
tree | c250924af2303df2e4d99e0b46a1117d40a0d108 /stecman | |
parent | f5555fef8e80d8380efb44dc8b7622a1de573c15 (diff) |
Add stecman/symfony-console-completion
Diffstat (limited to 'stecman')
10 files changed, 1439 insertions, 0 deletions
diff --git a/stecman/symfony-console-completion/LICENCE b/stecman/symfony-console-completion/LICENCE new file mode 100644 index 00000000..8f8e82c0 --- /dev/null +++ b/stecman/symfony-console-completion/LICENCE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Stephen Holdaway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
\ No newline at end of file diff --git a/stecman/symfony-console-completion/src/Completion.php b/stecman/symfony-console-completion/src/Completion.php new file mode 100644 index 00000000..f5adb45b --- /dev/null +++ b/stecman/symfony-console-completion/src/Completion.php @@ -0,0 +1,180 @@ +<?php + + +namespace Stecman\Component\Symfony\Console\BashCompletion; + +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionInterface; + +class Completion implements CompletionInterface +{ + /** + * The type of input (option/argument) the completion should be run for + * + * @see CompletionInterface::ALL_TYPES + * @var string + */ + protected $type; + + /** + * The command name the completion should be run for + * + * @see CompletionInterface::ALL_COMMANDS + * @var string|null + */ + protected $commandName; + + /** + * The option/argument name the completion should be run for + * + * @var string + */ + protected $targetName; + + /** + * Array of values to return, or a callback to generate completion results with + * The callback can be in any form accepted by call_user_func. + * + * @var callable|array + */ + protected $completion; + + /** + * Create a Completion with the command name set to CompletionInterface::ALL_COMMANDS + * + * @deprecated - This will be removed in 1.0.0 as it is redundant and isn't any more concise than what it implements. + * + * @param string $targetName + * @param string $type + * @param array|callable $completion + * @return Completion + */ + public static function makeGlobalHandler($targetName, $type, $completion) + { + return new Completion(CompletionInterface::ALL_COMMANDS, $targetName, $type, $completion); + } + + /** + * @param string $commandName + * @param string $targetName + * @param string $type + * @param array|callable $completion + */ + public function __construct($commandName, $targetName, $type, $completion) + { + $this->commandName = $commandName; + $this->targetName = $targetName; + $this->type = $type; + $this->completion = $completion; + } + + /** + * Return the stored completion, or the results returned from the completion callback + * + * @return array + */ + public function run() + { + if ($this->isCallable()) { + return call_user_func($this->completion); + } + + return $this->completion; + } + + /** + * Get type of input (option/argument) the completion should be run for + * + * @see CompletionInterface::ALL_TYPES + * @return string|null + */ + public function getType() + { + return $this->type; + } + + /** + * Set type of input (option/argument) the completion should be run for + * + * @see CompletionInterface::ALL_TYPES + * @param string|null $type + */ + public function setType($type) + { + $this->type = $type; + } + + /** + * Get the command name the completion should be run for + * + * @see CompletionInterface::ALL_COMMANDS + * @return string|null + */ + public function getCommandName() + { + return $this->commandName; + } + + /** + * Set the command name the completion should be run for + * + * @see CompletionInterface::ALL_COMMANDS + * @param string|null $commandName + */ + public function setCommandName($commandName) + { + $this->commandName = $commandName; + } + + /** + * Set the option/argument name the completion should be run for + * + * @see setType() + * @return string + */ + public function getTargetName() + { + return $this->targetName; + } + + /** + * Get the option/argument name the completion should be run for + * + * @see getType() + * @param string $targetName + */ + public function setTargetName($targetName) + { + $this->targetName = $targetName; + } + + /** + * Return the array or callback configured for for the Completion + * + * @return array|callable + */ + public function getCompletion() + { + return $this->completion; + } + + /** + * Set the array or callback to return/run when Completion is run + * + * @see run() + * @param array|callable $completion + */ + public function setCompletion($completion) + { + $this->completion = $completion; + } + + /** + * Check if the configured completion value is a callback function + * + * @return bool + */ + public function isCallable() + { + return is_callable($this->completion); + } +} diff --git a/stecman/symfony-console-completion/src/Completion/CompletionAwareInterface.php b/stecman/symfony-console-completion/src/Completion/CompletionAwareInterface.php new file mode 100644 index 00000000..20963cb8 --- /dev/null +++ b/stecman/symfony-console-completion/src/Completion/CompletionAwareInterface.php @@ -0,0 +1,27 @@ +<?php + +namespace Stecman\Component\Symfony\Console\BashCompletion\Completion; + +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; + +interface CompletionAwareInterface +{ + + /** + * Return possible values for the named option + * + * @param string $optionName + * @param CompletionContext $context + * @return array + */ + public function completeOptionValues($optionName, CompletionContext $context); + + /** + * Return possible values for the named argument + * + * @param string $argumentName + * @param CompletionContext $context + * @return array + */ + public function completeArgumentValues($argumentName, CompletionContext $context); +} diff --git a/stecman/symfony-console-completion/src/Completion/CompletionInterface.php b/stecman/symfony-console-completion/src/Completion/CompletionInterface.php new file mode 100644 index 00000000..4f1ca05a --- /dev/null +++ b/stecman/symfony-console-completion/src/Completion/CompletionInterface.php @@ -0,0 +1,48 @@ +<?php + + +namespace Stecman\Component\Symfony\Console\BashCompletion\Completion; + +interface CompletionInterface +{ + // Sugar for indicating that a Completion should run for all command names and for all types + // Intended to avoid meaningless null parameters in the constructors of implementing classes + const ALL_COMMANDS = null; + const ALL_TYPES = null; + + const TYPE_OPTION = 'option'; + const TYPE_ARGUMENT = 'argument'; + + /** + * Return the type of input (option/argument) completion should be run for + * + * @see \Symfony\Component\Console\Command\Command::addArgument + * @see \Symfony\Component\Console\Command\Command::addOption + * @return string - one of the CompletionInterface::TYPE_* constants + */ + public function getType(); + + /** + * Return the name of the command completion should be run for + * If the return value is CompletionInterface::ALL_COMMANDS, the completion will be run for any command name + * + * @see \Symfony\Component\Console\Command\Command::setName + * @return string|null + */ + public function getCommandName(); + + /** + * Return the option/argument name the completion should be run for + * CompletionInterface::getType determines whether the target name refers to an option or an argument + * + * @return string + */ + public function getTargetName(); + + /** + * Execute the completion + * + * @return string[] - an array of possible completion values + */ + public function run(); +} diff --git a/stecman/symfony-console-completion/src/Completion/ShellPathCompletion.php b/stecman/symfony-console-completion/src/Completion/ShellPathCompletion.php new file mode 100644 index 00000000..c8f92296 --- /dev/null +++ b/stecman/symfony-console-completion/src/Completion/ShellPathCompletion.php @@ -0,0 +1,65 @@ +<?php + + +namespace Stecman\Component\Symfony\Console\BashCompletion\Completion; + +/** + * Shell Path Completion + * + * Defers completion to the calling shell's built-in path completion functionality. + */ +class ShellPathCompletion implements CompletionInterface +{ + /** + * Exit code set up to trigger path completion in the completion hooks + * @see Stecman\Component\Symfony\Console\BashCompletion\HookFactory + */ + const PATH_COMPLETION_EXIT_CODE = 200; + + protected $type; + + protected $commandName; + + protected $targetName; + + public function __construct($commandName, $targetName, $type) + { + $this->commandName = $commandName; + $this->targetName = $targetName; + $this->type = $type; + } + + /** + * @inheritdoc + */ + public function getType() + { + return $this->type; + } + + /** + * @inheritdoc + */ + public function getCommandName() + { + return $this->commandName; + } + + /** + * @inheritdoc + */ + public function getTargetName() + { + return $this->targetName; + } + + /** + * Exit with a status code configured to defer completion to the shell + * + * @see \Stecman\Component\Symfony\Console\BashCompletion\HookFactory::$hooks + */ + public function run() + { + exit(self::PATH_COMPLETION_EXIT_CODE); + } +} diff --git a/stecman/symfony-console-completion/src/CompletionCommand.php b/stecman/symfony-console-completion/src/CompletionCommand.php new file mode 100644 index 00000000..b51666a1 --- /dev/null +++ b/stecman/symfony-console-completion/src/CompletionCommand.php @@ -0,0 +1,144 @@ +<?php + +namespace Stecman\Component\Symfony\Console\BashCompletion; + +use Symfony\Component\Console\Command\Command as SymfonyCommand; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class CompletionCommand extends SymfonyCommand +{ + + /** + * @var CompletionHandler + */ + protected $handler; + + protected function configure() + { + $this + ->setName('_completion') + ->setDefinition($this->createDefinition()) + ->setDescription('BASH completion hook.') + ->setHelp(<<<END +To enable BASH completion, run: + + <comment>eval `[program] _completion -g`</comment>. + +Or for an alias: + + <comment>eval `[program] _completion -g -p [alias]`</comment>. + +END + ); + } + + /** + * {@inheritdoc} + */ + public function getNativeDefinition() + { + return $this->createDefinition(); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->handler = new CompletionHandler($this->getApplication()); + $handler = $this->handler; + + if ($input->getOption('generate-hook')) { + global $argv; + $program = $argv[0]; + + $factory = new HookFactory(); + $alias = $input->getOption('program'); + $multiple = (bool)$input->getOption('multiple'); + + // When completing for multiple apps having absolute path in the alias doesn't make sense. + if (!$alias && $multiple) { + $alias = basename($program); + } + + $hook = $factory->generateHook( + $input->getOption('shell-type') ?: $this->getShellType(), + $program, + $alias, + $multiple + ); + + $output->write($hook, true); + } else { + $handler->setContext(new EnvironmentCompletionContext()); + $output->write($this->runCompletion(), true); + } + } + + /** + * Run the completion handler and return a filtered list of results + * + * @deprecated - This will be removed in 1.0.0 in favour of CompletionCommand::configureCompletion + * + * @return string[] + */ + protected function runCompletion() + { + $this->configureCompletion($this->handler); + return $this->handler->runCompletion(); + } + + /** + * Configure the CompletionHandler instance before it is run + * + * @param CompletionHandler $handler + */ + protected function configureCompletion(CompletionHandler $handler) + { + // Override this method to configure custom value completions + } + + /** + * Determine the shell type for use with HookFactory + * + * @return string + */ + protected function getShellType() + { + if (!getenv('SHELL')) { + throw new \RuntimeException('Could not read SHELL environment variable. Please specify your shell type using the --shell-type option.'); + } + + return basename(getenv('SHELL')); + } + + protected function createDefinition() + { + return new InputDefinition(array( + new InputOption( + 'generate-hook', + 'g', + InputOption::VALUE_NONE, + 'Generate BASH code that sets up completion for this application.' + ), + new InputOption( + 'program', + 'p', + InputOption::VALUE_REQUIRED, + "Program name that should trigger completion\n<comment>(defaults to the absolute application path)</comment>." + ), + new InputOption( + 'multiple', + 'm', + InputOption::VALUE_NONE, + "Generated hook can be used for multiple applications." + ), + new InputOption( + 'shell-type', + null, + InputOption::VALUE_OPTIONAL, + 'Set the shell type (zsh or bash). Otherwise this is determined automatically.' + ), + )); + } +} diff --git a/stecman/symfony-console-completion/src/CompletionContext.php b/stecman/symfony-console-completion/src/CompletionContext.php new file mode 100644 index 00000000..60292534 --- /dev/null +++ b/stecman/symfony-console-completion/src/CompletionContext.php @@ -0,0 +1,256 @@ +<?php + + +namespace Stecman\Component\Symfony\Console\BashCompletion; + +/** + * Command line context for completion + * + * Represents the current state of the command line that is being completed + */ +class CompletionContext +{ + /** + * The current contents of the command line as a single string + * + * Bash equivalent: COMP_LINE + * + * @var string + */ + protected $commandLine; + + /** + * The index of the user's cursor relative to the start of the command line. + * + * If the current cursor position is at the end of the current command, + * the value of this variable is equal to the length of $this->commandLine + * + * Bash equivalent: COMP_POINT + * + * @var int + */ + protected $charIndex = 0; + + /** + * An array containing the individual words in the current command line. + * + * This is not set until $this->splitCommand() is called, when it is populated by + * $commandLine exploded by $wordBreaks + * + * Bash equivalent: COMP_WORDS + * + * @var array|null + */ + protected $words = null; + + /** + * The index in $this->words containing the word at the current cursor position. + * + * This is not set until $this->splitCommand() is called. + * + * Bash equivalent: COMP_CWORD + * + * @var int|null + */ + protected $wordIndex = null; + + /** + * Characters that $this->commandLine should be split on to get a list of individual words + * + * Bash equivalent: COMP_WORDBREAKS + * + * @var string + */ + protected $wordBreaks = "'\"()= \t\n"; + + /** + * Set the whole contents of the command line as a string + * + * @param string $commandLine + */ + public function setCommandLine($commandLine) + { + $this->commandLine = $commandLine; + $this->reset(); + } + + /** + * Return the current command line verbatim as a string + * + * @return string + */ + public function getCommandLine() + { + return $this->commandLine; + } + + /** + * Return the word from the command line that the cursor is currently in + * + * Most of the time this will be a partial word. If the cursor has a space before it, + * this will return an empty string, indicating a new word. + * + * @return string + */ + public function getCurrentWord() + { + if (isset($this->words[$this->wordIndex])) { + return $this->words[$this->wordIndex]; + } + + return ''; + } + + /** + * Return a word by index from the command line + * + * @see $words, $wordBreaks + * @param int $index + * @return string + */ + public function getWordAtIndex($index) + { + if (isset($this->words[$index])) { + return $this->words[$index]; + } + + return ''; + } + + /** + * Get the contents of the command line, exploded into words based on the configured word break characters + * + * @see $wordBreaks, setWordBreaks + * @return array + */ + public function getWords() + { + if ($this->words === null) { + $this->splitCommand(); + } + + return $this->words; + } + + /** + * Get the index of the word the cursor is currently in + * + * @see getWords, getCurrentWord + * @return int + */ + public function getWordIndex() + { + if ($this->wordIndex === null) { + $this->splitCommand(); + } + + return $this->wordIndex; + } + + /** + * Get the character index of the user's cursor on the command line + * + * This is in the context of the full command line string, so includes word break characters. + * Note that some shells can only provide an approximation for character index. Under ZSH for + * example, this will always be the character at the start of the current word. + * + * @return int + */ + public function getCharIndex() + { + return $this->charIndex; + } + + /** + * Set the cursor position as a character index relative to the start of the command line + * + * @param int $index + */ + public function setCharIndex($index) + { + $this->charIndex = $index; + $this->reset(); + } + + /** + * Set characters to use as split points when breaking the command line into words + * + * This defaults to a sane value based on BASH's word break characters and shouldn't + * need to be changed unless your completions contain the default word break characters. + * + * @see wordBreaks + * @param string $charList - a single string containing all of the characters to break words on + */ + public function setWordBreaks($charList) + { + $this->wordBreaks = $charList; + $this->reset(); + } + + /** + * Split the command line into words using the configured word break characters + * + * @return string[] + */ + protected function splitCommand() + { + $this->words = array(); + $this->wordIndex = null; + $cursor = 0; + + $breaks = preg_quote($this->wordBreaks); + + if (!preg_match_all("/([^$breaks]*)([$breaks]*)/", $this->commandLine, $matches)) { + return; + } + + // Groups: + // 1: Word + // 2: Break characters + foreach ($matches[0] as $index => $wholeMatch) { + // Determine which word the cursor is in + $cursor += strlen($wholeMatch); + $word = $matches[1][$index]; + $breaks = $matches[2][$index]; + + if ($this->wordIndex === null && $cursor >= $this->charIndex) { + $this->wordIndex = $index; + + // Find the user's cursor position relative to the end of this word + // The end of the word is the internal cursor minus any break characters that were captured + $cursorWordOffset = $this->charIndex - ($cursor - strlen($breaks)); + + if ($cursorWordOffset < 0) { + // Cursor is inside the word - truncate the word at the cursor + // (This emulates normal BASH completion behaviour I've observed, though I'm not entirely sure if it's useful) + $word = substr($word, 0, strlen($word) + $cursorWordOffset); + + } elseif ($cursorWordOffset > 0) { + // Cursor is in the break-space after a word + // Push an empty word at the cursor to allow completion of new terms at the cursor, ignoring words ahead + $this->wordIndex++; + $this->words[] = $word; + $this->words[] = ''; + continue; + } + } + + if ($word !== '') { + $this->words[] = $word; + } + } + + if ($this->wordIndex > count($this->words) - 1) { + $this->wordIndex = count($this->words) - 1; + } + } + + /** + * Reset the computed words so that $this->splitWords is forced to run again + */ + protected function reset() + { + $this->words = null; + $this->wordIndex = null; + } +} diff --git a/stecman/symfony-console-completion/src/CompletionHandler.php b/stecman/symfony-console-completion/src/CompletionHandler.php new file mode 100644 index 00000000..531fae33 --- /dev/null +++ b/stecman/symfony-console-completion/src/CompletionHandler.php @@ -0,0 +1,445 @@ +<?php + +namespace Stecman\Component\Symfony\Console\BashCompletion; + +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionInterface; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; + +class CompletionHandler +{ + /** + * Application to complete for + * @var \Symfony\Component\Console\Application + */ + protected $application; + + /** + * @var Command + */ + protected $command; + + /** + * @var CompletionContext + */ + protected $context; + + /** + * Array of completion helpers. + * @var CompletionInterface[] + */ + protected $helpers = array(); + + public function __construct(Application $application, CompletionContext $context = null) + { + $this->application = $application; + $this->context = $context; + + $this->addHandler( + new Completion( + 'help', + 'command_name', + Completion::TYPE_ARGUMENT, + array_keys($application->all()) + ) + ); + + $this->addHandler( + new Completion( + 'list', + 'namespace', + Completion::TYPE_ARGUMENT, + $application->getNamespaces() + ) + ); + } + + public function setContext(CompletionContext $context) + { + $this->context = $context; + } + + /** + * @return CompletionContext + */ + public function getContext() + { + return $this->context; + } + + /** + * @param CompletionInterface[] $array + */ + public function addHandlers(array $array) + { + $this->helpers = array_merge($this->helpers, $array); + } + + /** + * @param CompletionInterface $helper + */ + public function addHandler(CompletionInterface $helper) + { + $this->helpers[] = $helper; + } + + /** + * Do the actual completion, returning an array of strings to provide to the parent shell's completion system + * + * @throws \RuntimeException + * @return string[] + */ + public function runCompletion() + { + if (!$this->context) { + throw new \RuntimeException('A CompletionContext must be set before requesting completion.'); + } + + $cmdName = $this->getInput()->getFirstArgument(); + + try { + $this->command = $this->application->find($cmdName); + } catch (\InvalidArgumentException $e) { + // Exception thrown, when multiple or none commands are found. + } + + $process = array( + 'completeForOptionValues', + 'completeForOptionShortcuts', + 'completeForOptionShortcutValues', + 'completeForOptions', + 'completeForCommandName', + 'completeForCommandArguments' + ); + + foreach ($process as $methodName) { + $result = $this->{$methodName}(); + + if (false !== $result) { + // Return the result of the first completion mode that matches + return $this->filterResults((array) $result); + } + } + + return array(); + } + + /** + * Get an InputInterface representation of the completion context + * + * @return ArrayInput + */ + public function getInput() + { + // Filter the command line content to suit ArrayInput + $words = $this->context->getWords(); + array_shift($words); + $words = array_filter($words); + + return new ArrayInput($words); + } + + /** + * Attempt to complete the current word as a long-form option (--my-option) + * + * @return array|false + */ + protected function completeForOptions() + { + $word = $this->context->getCurrentWord(); + + if (substr($word, 0, 2) === '--') { + $options = array(); + + foreach ($this->getAllOptions() as $opt) { + $options[] = '--'.$opt->getName(); + } + + return $options; + } + + return false; + } + + /** + * Attempt to complete the current word as an option shortcut. + * + * If the shortcut exists it will be completed, but a list of possible shortcuts is never returned for completion. + * + * @return array|false + */ + protected function completeForOptionShortcuts() + { + $word = $this->context->getCurrentWord(); + + if (strpos($word, '-') === 0 && strlen($word) == 2) { + $definition = $this->command ? $this->command->getNativeDefinition() : $this->application->getDefinition(); + + if ($definition->hasShortcut(substr($word, 1))) { + return array($word); + } + } + + return false; + } + + /** + * Attempt to complete the current word as the value of an option shortcut + * + * @return array|false + */ + protected function completeForOptionShortcutValues() + { + $wordIndex = $this->context->getWordIndex(); + + if ($this->command && $wordIndex > 1) { + $left = $this->context->getWordAtIndex($wordIndex - 1); + + // Complete short options + if ($left[0] == '-' && strlen($left) == 2) { + $shortcut = substr($left, 1); + $def = $this->command->getNativeDefinition(); + + if (!$def->hasShortcut($shortcut)) { + return false; + } + + $opt = $def->getOptionForShortcut($shortcut); + if ($opt->isValueRequired() || $opt->isValueOptional()) { + return $this->completeOption($opt); + } + } + } + + return false; + } + + /** + * Attemp to complete the current word as the value of a long-form option + * + * @return array|false + */ + protected function completeForOptionValues() + { + $wordIndex = $this->context->getWordIndex(); + + if ($this->command && $wordIndex > 1) { + $left = $this->context->getWordAtIndex($wordIndex - 1); + + if (strpos($left, '--') === 0) { + $name = substr($left, 2); + $def = $this->command->getNativeDefinition(); + + if (!$def->hasOption($name)) { + return false; + } + + $opt = $def->getOption($name); + if ($opt->isValueRequired() || $opt->isValueOptional()) { + return $this->completeOption($opt); + } + } + } + + return false; + } + + /** + * Attempt to complete the current word as a command name + * + * @return array|false + */ + protected function completeForCommandName() + { + if (!$this->command || (count($this->context->getWords()) == 2 && $this->context->getWordIndex() == 1)) { + $commands = $this->application->all(); + $names = array_keys($commands); + + if ($key = array_search('_completion', $names)) { + unset($names[$key]); + } + + return $names; + } + + return false; + } + + /** + * Attempt to complete the current word as a command argument value + * + * @see Symfony\Component\Console\Input\InputArgument + * @return array|false + */ + protected function completeForCommandArguments() + { + if (!$this->command || strpos($this->context->getCurrentWord(), '-') === 0) { + return false; + } + + $definition = $this->command->getNativeDefinition(); + $argWords = $this->mapArgumentsToWords($definition->getArguments()); + $wordIndex = $this->context->getWordIndex(); + + if (isset($argWords[$wordIndex])) { + $name = $argWords[$wordIndex]; + } elseif (!empty($argWords) && $definition->getArgument(end($argWords))->isArray()) { + $name = end($argWords); + } else { + return false; + } + + if ($helper = $this->getCompletionHelper($name, Completion::TYPE_ARGUMENT)) { + return $helper->run(); + } + + if ($this->command instanceof CompletionAwareInterface) { + return $this->command->completeArgumentValues($name, $this->context); + } + + return false; + } + + /** + * Find a CompletionInterface that matches the current command, target name, and target type + * + * @param string $name + * @param string $type + * @return CompletionInterface|null + */ + protected function getCompletionHelper($name, $type) + { + foreach ($this->helpers as $helper) { + if ($helper->getType() != $type && $helper->getType() != CompletionInterface::ALL_TYPES) { + continue; + } + + if ($helper->getCommandName() == CompletionInterface::ALL_COMMANDS || $helper->getCommandName() == $this->command->getName()) { + if ($helper->getTargetName() == $name) { + return $helper; + } + } + } + + return null; + } + + /** + * Complete the value for the given option if a value completion is availble + * + * @param InputOption $option + * @return array|false + */ + protected function completeOption(InputOption $option) + { + if ($helper = $this->getCompletionHelper($option->getName(), Completion::TYPE_OPTION)) { + return $helper->run(); + } + + if ($this->command instanceof CompletionAwareInterface) { + return $this->command->completeOptionValues($option->getName(), $this->context); + } + + return false; + } + + /** + * Step through the command line to determine which word positions represent which argument values + * + * The word indexes of argument values are found by eliminating words that are known to not be arguments (options, + * option values, and command names). Any word that doesn't match for elimination is assumed to be an argument value, + * + * @param InputArgument[] $argumentDefinitions + * @return array as [argument name => word index on command line] + */ + protected function mapArgumentsToWords($argumentDefinitions) + { + $argumentPositions = array(); + $argumentNumber = 0; + $previousWord = null; + $argumentNames = array_keys($argumentDefinitions); + + // Build a list of option values to filter out + $optionsWithArgs = $this->getOptionWordsWithValues(); + + foreach ($this->context->getWords() as $wordIndex => $word) { + // Skip program name, command name, options, and option values + if ($wordIndex < 2 + || ($word && '-' === $word[0]) + || in_array($previousWord, $optionsWithArgs)) { + $previousWord = $word; + continue; + } else { + $previousWord = $word; + } + + // If argument n exists, pair that argument's name with the current word + if (isset($argumentNames[$argumentNumber])) { + $argumentPositions[$wordIndex] = $argumentNames[$argumentNumber]; + } + + $argumentNumber++; + } + + return $argumentPositions; + } + + /** + * Build a list of option words/flags that will have a value after them + * Options are returned in the format they appear as on the command line. + * + * @return string[] - eg. ['--myoption', '-m', ... ] + */ + protected function getOptionWordsWithValues() + { + $strings = array(); + + foreach ($this->getAllOptions() as $option) { + if ($option->isValueRequired()) { + $strings[] = '--' . $option->getName(); + + if ($option->getShortcut()) { + $strings[] = '-' . $option->getShortcut(); + } + } + } + + return $strings; + } + + /** + * Filter out results that don't match the current word on the command line + * + * @param string[] $array + * @return string[] + */ + protected function filterResults(array $array) + { + $curWord = $this->context->getCurrentWord(); + + return array_filter($array, function($val) use ($curWord) { + return fnmatch($curWord.'*', $val); + }); + } + + /** + * Get the combined options of the application and entered command + * + * @return InputOption[] + */ + protected function getAllOptions() + { + if (!$this->command) { + return $this->application->getDefinition()->getOptions(); + } + + return array_merge( + $this->command->getNativeDefinition()->getOptions(), + $this->application->getDefinition()->getOptions() + ); + } +} diff --git a/stecman/symfony-console-completion/src/EnvironmentCompletionContext.php b/stecman/symfony-console-completion/src/EnvironmentCompletionContext.php new file mode 100644 index 00000000..04027ce1 --- /dev/null +++ b/stecman/symfony-console-completion/src/EnvironmentCompletionContext.php @@ -0,0 +1,46 @@ +<?php + + +namespace Stecman\Component\Symfony\Console\BashCompletion; + +class EnvironmentCompletionContext extends CompletionContext +{ + /** + * Set up completion context from the environment variables set by the parent shell + */ + public function __construct() + { + $this->commandLine = getenv('CMDLINE_CONTENTS'); + $this->charIndex = intval(getenv('CMDLINE_CURSOR_INDEX')); + + if ($this->commandLine === false) { + $message = 'Failed to configure from environment; Environment var CMDLINE_CONTENTS not set.'; + + if (getenv('COMP_LINE')) { + $message .= "\n\nYou appear to be attempting completion using an out-dated hook. If you've just updated," + . " you probably need to reinitialise the completion shell hook by reloading your shell" + . " profile or starting a new shell session. If you are using a hard-coded (rather than generated)" + . " hook, you will need to update that function with the new environment variable names." + . "\n\nSee here for details: https://github.com/stecman/symfony-console-completion/issues/31"; + } + + throw new \RuntimeException($message); + } + } + + /** + * Use the word break characters set by the parent shell. + * + * @throws \RuntimeException + */ + public function useWordBreaksFromEnvironment() + { + $breaks = getenv('CMDLINE_WORDBREAKS'); + + if (!$breaks) { + throw new \RuntimeException('Failed to read word breaks from environment; Environment var CMDLINE_WORDBREAKS not set'); + } + + $this->wordBreaks = $breaks; + } +} diff --git a/stecman/symfony-console-completion/src/HookFactory.php b/stecman/symfony-console-completion/src/HookFactory.php new file mode 100644 index 00000000..19601e8b --- /dev/null +++ b/stecman/symfony-console-completion/src/HookFactory.php @@ -0,0 +1,207 @@ +<?php + + +namespace Stecman\Component\Symfony\Console\BashCompletion; + +final class HookFactory +{ + /** + * Hook scripts + * + * These are shell-specific scripts that pass required information from that shell's + * completion system to the interface of the completion command in this module. + * + * The following placeholders are replaced with their value at runtime: + * + * %%function_name%% - name of the generated shell function run for completion + * %%program_name%% - command name completion will be enabled for + * %%program_path%% - path to program the completion is for/generated by + * %%completion_command%% - command to be run to compute completions + * + * NOTE: Comments are stripped out by HookFactory::stripComments as eval reads + * input as a single line, causing it to break if comments are included. + * While comments work using `... | source /dev/stdin`, existing installations + * are likely using eval as it's been part of the instructions for a while. + * + * @var array + */ + protected static $hooks = array( + // BASH Hook + 'bash' => <<<'END' +# BASH completion for %%program_path%% +function %%function_name%% { + + # Copy BASH's completion variables to the ones the completion command expects + # These line up exactly as the library was originally designed for BASH + local CMDLINE_CONTENTS="$COMP_LINE" + local CMDLINE_CURSOR_INDEX="$COMP_POINT" + local CMDLINE_WORDBREAKS="$COMP_WORDBREAKS"; + + export CMDLINE_CONTENTS CMDLINE_CURSOR_INDEX CMDLINE_WORDBREAKS + + local RESULT STATUS; + + RESULT="$(%%completion_command%% </dev/null)"; + STATUS=$?; + + local cur mail_check_backup; + + mail_check_backup=$MAILCHECK; + MAILCHECK=-1; + + _get_comp_words_by_ref -n : cur; + + # Check if shell provided path completion is requested + # @see Completion\ShellPathCompletion + if [ $STATUS -eq 200 ]; then + _filedir; + return 0; + + # Bail out if PHP didn't exit cleanly + elif [ $STATUS -ne 0 ]; then + echo -e "$RESULT"; + return $?; + fi; + + COMPREPLY=(`compgen -W "$RESULT" -- $cur`); + + __ltrim_colon_completions "$cur"; + + MAILCHECK=mail_check_backup; +}; + +if [ "$(type -t _get_comp_words_by_ref)" == "function" ]; then + complete -F %%function_name%% "%%program_name%%"; +else + >&2 echo "Completion was not registered for %%program_name%%:"; + >&2 echo "The 'bash-completion' package is required but doesn't appear to be installed."; +fi +END + + // ZSH Hook + , 'zsh' => <<<'END' +# ZSH completion for %%program_path%% +function %%function_name%% { + local -x CMDLINE_CONTENTS="$words" + local -x CMDLINE_CURSOR_INDEX + (( CMDLINE_CURSOR_INDEX = ${#${(j. .)words[1,CURRENT]}} )) + + local RESULT STATUS + RESULT=("${(@f)$( %%completion_command%% )}") + STATUS=$?; + + # Check if shell provided path completion is requested + # @see Completion\ShellPathCompletion + if [ $STATUS -eq 200 ]; then + _path_files; + return 0; + + # Bail out if PHP didn't exit cleanly + elif [ $STATUS -ne 0 ]; then + echo -e "$RESULT"; + return $?; + fi; + + compadd -- $RESULT +}; + +compdef %%function_name%% "%%program_name%%"; +END + ); + + /** + * Return the names of shells that have hooks + * + * @return string[] + */ + public static function getShellTypes() + { + return array_keys(self::$hooks); + } + + /** + * Return a completion hook for the specified shell type + * + * @param string $type - a key from self::$hooks + * @param string $programPath + * @param string $programName + * @param bool $multiple + * + * @return string + */ + public function generateHook($type, $programPath, $programName = null, $multiple = false) + { + if (!isset(self::$hooks[$type])) { + throw new \RuntimeException(sprintf( + "Cannot generate hook for unknown shell type '%s'. Available hooks are: %s", + $type, + implode(', ', self::getShellTypes()) + )); + } + + // Use the program path if an alias/name is not given + $programName = $programName ?: $programPath; + + if ($multiple) { + $completionCommand = '$1 _completion'; + } else { + $completionCommand = $programPath . ' _completion'; + } + + return str_replace( + array( + '%%function_name%%', + '%%program_name%%', + '%%program_path%%', + '%%completion_command%%', + ), + array( + $this->generateFunctionName($programPath, $programName), + $programName, + $programPath, + $completionCommand + ), + $this->stripComments(self::$hooks[$type]) + ); + } + + /** + * Generate a function name that is unlikely to conflict with other generated function names in the same shell + */ + protected function generateFunctionName($programPath, $programName) + { + return sprintf( + '_%s_%s_complete', + $this->sanitiseForFunctionName(basename($programName)), + substr(md5($programPath), 0, 16) + ); + } + + + /** + * Make a string safe for use as a shell function name + * + * @param string $name + * @return string + */ + protected function sanitiseForFunctionName($name) + { + $name = str_replace('-', '_', $name); + return preg_replace('/[^A-Za-z0-9_]+/', '', $name); + } + + /** + * Strip '#' style comments from a string + * + * BASH's eval doesn't work with comments as it removes line breaks, so comments have to be stripped out + * for this method of sourcing the hook to work. Eval seems to be the most reliable method of getting a + * hook into a shell, so while it would be nice to render comments, this stripping is required for now. + * + * @param string $script + * @return string + */ + protected function stripComments($script) + { + return preg_replace('/(^\s*\#.*$)/m', '', $script); + } +} |