From b6ef8a42ae8c7d4d69c6acdf919072a7989648d7 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Wed, 21 Sep 2016 00:03:15 +0200 Subject: [PATCH] Add stecman/symfony-console-completion --- .gitignore | 7 + composer.json | 3 +- composer.lock | 49 +- composer/ClassLoader.php | 12 +- composer/autoload_classmap.php | 9 + composer/autoload_files.php | 2 +- composer/autoload_psr4.php | 1 + composer/autoload_static.php | 16 +- composer/installed.json | 47 ++ stecman/symfony-console-completion/LICENCE | 21 + .../src/Completion.php | 180 +++++++ .../Completion/CompletionAwareInterface.php | 27 ++ .../src/Completion/CompletionInterface.php | 48 ++ .../src/Completion/ShellPathCompletion.php | 65 +++ .../src/CompletionCommand.php | 144 ++++++ .../src/CompletionContext.php | 256 ++++++++++ .../src/CompletionHandler.php | 445 ++++++++++++++++++ .../src/EnvironmentCompletionContext.php | 46 ++ .../src/HookFactory.php | 207 ++++++++ 19 files changed, 1575 insertions(+), 10 deletions(-) create mode 100644 stecman/symfony-console-completion/LICENCE create mode 100644 stecman/symfony-console-completion/src/Completion.php create mode 100644 stecman/symfony-console-completion/src/Completion/CompletionAwareInterface.php create mode 100644 stecman/symfony-console-completion/src/Completion/CompletionInterface.php create mode 100644 stecman/symfony-console-completion/src/Completion/ShellPathCompletion.php create mode 100644 stecman/symfony-console-completion/src/CompletionCommand.php create mode 100644 stecman/symfony-console-completion/src/CompletionContext.php create mode 100644 stecman/symfony-console-completion/src/CompletionHandler.php create mode 100644 stecman/symfony-console-completion/src/EnvironmentCompletionContext.php create mode 100644 stecman/symfony-console-completion/src/HookFactory.php diff --git a/.gitignore b/.gitignore index 9b5f8abd6..f475acc55 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,13 @@ sabre/uri/tests sabre/vobject/tests sabre/xml/tests +stecman/symfony-console-completion/tests +stecman/symfony-console-completion/.gitignore +stecman/symfony-console-completion/.travis.yml +stecman/symfony-console-completion/composer.json +stecman/symfony-console-completion/phpunit.xml.dist +stecman/symfony-console-completion/README.md + mcnetic/zipstreamer/tests ircmaxell/security-lib/tests diff --git a/composer.json b/composer.json index dd332b5cc..848f0521d 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "patchwork/jsqueeze": "^2.0", "sabre/dav": "3.0.9", "symfony/polyfill-php70": "^1.0", - "lukasreschke/id3parser": "^0.0.1" + "lukasreschke/id3parser": "^0.0.1", + "stecman/symfony-console-completion": "^0.7.0" } } diff --git a/composer.lock b/composer.lock index 67de722df..c6f7b7e22 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "f23667323b1e59e1360d564052bd3bbf", - "content-hash": "9b82d5f7a30db1ddad2bce0c0ad9d25d", + "hash": "223aeddebd1fad634a94f284a0c4d2e2", + "content-hash": "64913ca1f9ccf0088f4b087d740c7988", "packages": [ { "name": "bantu/ini-get-wrapper", @@ -2349,6 +2349,51 @@ ], "time": "2016-03-12 22:23:16" }, + { + "name": "stecman/symfony-console-completion", + "version": "0.7.0", + "source": { + "type": "git", + "url": "https://github.com/stecman/symfony-console-completion.git", + "reference": "5461d43e53092b3d3b9dbd9d999f2054730f4bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stecman/symfony-console-completion/zipball/5461d43e53092b3d3b9dbd9d999f2054730f4bbb", + "reference": "5461d43e53092b3d3b9dbd9d999f2054730f4bbb", + "shasum": "" + }, + "require": { + "php": ">=5.3.2", + "symfony/console": "~2.3 || ~3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Stecman\\Component\\Symfony\\Console\\BashCompletion\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stephen Holdaway", + "email": "stephen@stecman.co.nz" + } + ], + "description": "Automatic BASH completion for Symfony Console Component based applications.", + "time": "2016-02-24 05:08:54" + }, { "name": "swiftmailer/swiftmailer", "version": "v5.3.1", diff --git a/composer/ClassLoader.php b/composer/ClassLoader.php index ff6ecfb82..ac67d302a 100644 --- a/composer/ClassLoader.php +++ b/composer/ClassLoader.php @@ -53,8 +53,8 @@ class ClassLoader private $useIncludePath = false; private $classMap = array(); - private $classMapAuthoritative = false; + private $missingClasses = array(); public function getPrefixes() { @@ -322,20 +322,20 @@ public function findFile($class) if (isset($this->classMap[$class])) { return $this->classMap[$class]; } - if ($this->classMapAuthoritative) { + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { return false; } $file = $this->findFileWithExtension($class, '.php'); // Search for Hack files if we are running on HHVM - if ($file === null && defined('HHVM_VERSION')) { + if (false === $file && defined('HHVM_VERSION')) { $file = $this->findFileWithExtension($class, '.hh'); } - if ($file === null) { + if (false === $file) { // Remember that this class does not exist. - return $this->classMap[$class] = false; + $this->missingClasses[$class] = true; } return $file; @@ -399,6 +399,8 @@ private function findFileWithExtension($class, $ext) if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { return $file; } + + return false; } } diff --git a/composer/autoload_classmap.php b/composer/autoload_classmap.php index 9da064c4b..6c46bd88e 100644 --- a/composer/autoload_classmap.php +++ b/composer/autoload_classmap.php @@ -1296,6 +1296,15 @@ 'Sabre\\Xml\\Writer' => $vendorDir . '/sabre/xml/lib/Writer.php', 'Sabre\\Xml\\XmlDeserializable' => $vendorDir . '/sabre/xml/lib/XmlDeserializable.php', 'Sabre\\Xml\\XmlSerializable' => $vendorDir . '/sabre/xml/lib/XmlSerializable.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\Completion' => $vendorDir . '/stecman/symfony-console-completion/src/Completion.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\CompletionCommand' => $vendorDir . '/stecman/symfony-console-completion/src/CompletionCommand.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\CompletionContext' => $vendorDir . '/stecman/symfony-console-completion/src/CompletionContext.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\CompletionHandler' => $vendorDir . '/stecman/symfony-console-completion/src/CompletionHandler.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\Completion\\CompletionAwareInterface' => $vendorDir . '/stecman/symfony-console-completion/src/Completion/CompletionAwareInterface.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\Completion\\CompletionInterface' => $vendorDir . '/stecman/symfony-console-completion/src/Completion/CompletionInterface.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\Completion\\ShellPathCompletion' => $vendorDir . '/stecman/symfony-console-completion/src/Completion/ShellPathCompletion.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\EnvironmentCompletionContext' => $vendorDir . '/stecman/symfony-console-completion/src/EnvironmentCompletionContext.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\HookFactory' => $vendorDir . '/stecman/symfony-console-completion/src/HookFactory.php', 'SuperClosure\\Analyzer\\AstAnalyzer' => $vendorDir . '/jeremeamia/SuperClosure/src/Analyzer/AstAnalyzer.php', 'SuperClosure\\Analyzer\\ClosureAnalyzer' => $vendorDir . '/jeremeamia/SuperClosure/src/Analyzer/ClosureAnalyzer.php', 'SuperClosure\\Analyzer\\Token' => $vendorDir . '/jeremeamia/SuperClosure/src/Analyzer/Token.php', diff --git a/composer/autoload_files.php b/composer/autoload_files.php index 3abd10f33..7419c9aef 100644 --- a/composer/autoload_files.php +++ b/composer/autoload_files.php @@ -7,8 +7,8 @@ return array( '383eaff206634a77a1be54e64e6459c7' => $vendorDir . '/sabre/uri/lib/functions.php', - 'ad155f8f1cf0d418fe49e248db8c661b' => $vendorDir . '/react/promise/src/functions_include.php', '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php', + 'ad155f8f1cf0d418fe49e248db8c661b' => $vendorDir . '/react/promise/src/functions_include.php', 'fe1bcd0336136e435eaf197895daf81a' => $vendorDir . '/nikic/php-parser/lib/bootstrap.php', 'ebdb698ed4152ae445614b69b5e4bb6a' => $vendorDir . '/sabre/http/lib/functions.php', '3569eecfeed3bcf0bad3c998a494ecb8' => $vendorDir . '/sabre/xml/lib/Deserializer/functions.php', diff --git a/composer/autoload_psr4.php b/composer/autoload_psr4.php index c8c2d85dd..98ad467c8 100644 --- a/composer/autoload_psr4.php +++ b/composer/autoload_psr4.php @@ -17,6 +17,7 @@ 'Symfony\\Component\\EventDispatcher\\' => array($vendorDir . '/symfony/event-dispatcher'), 'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'), 'SuperClosure\\' => array($vendorDir . '/jeremeamia/SuperClosure/src'), + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\' => array($vendorDir . '/stecman/symfony-console-completion/src'), 'Sabre\\Xml\\' => array($vendorDir . '/sabre/xml/lib'), 'Sabre\\VObject\\' => array($vendorDir . '/sabre/vobject/lib'), 'Sabre\\Uri\\' => array($vendorDir . '/sabre/uri/lib'), diff --git a/composer/autoload_static.php b/composer/autoload_static.php index 4bcf706f0..426c872e2 100644 --- a/composer/autoload_static.php +++ b/composer/autoload_static.php @@ -8,8 +8,8 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 { public static $files = array ( '383eaff206634a77a1be54e64e6459c7' => __DIR__ . '/..' . '/sabre/uri/lib/functions.php', - 'ad155f8f1cf0d418fe49e248db8c661b' => __DIR__ . '/..' . '/react/promise/src/functions_include.php', '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php', + 'ad155f8f1cf0d418fe49e248db8c661b' => __DIR__ . '/..' . '/react/promise/src/functions_include.php', 'fe1bcd0336136e435eaf197895daf81a' => __DIR__ . '/..' . '/nikic/php-parser/lib/bootstrap.php', 'ebdb698ed4152ae445614b69b5e4bb6a' => __DIR__ . '/..' . '/sabre/http/lib/functions.php', '3569eecfeed3bcf0bad3c998a494ecb8' => __DIR__ . '/..' . '/sabre/xml/lib/Deserializer/functions.php', @@ -46,6 +46,7 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 'Symfony\\Component\\EventDispatcher\\' => 34, 'Symfony\\Component\\Console\\' => 26, 'SuperClosure\\' => 13, + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\' => 49, 'Sabre\\Xml\\' => 10, 'Sabre\\VObject\\' => 14, 'Sabre\\Uri\\' => 10, @@ -133,6 +134,10 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 array ( 0 => __DIR__ . '/..' . '/jeremeamia/SuperClosure/src', ), + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\' => + array ( + 0 => __DIR__ . '/..' . '/stecman/symfony-console-completion/src', + ), 'Sabre\\Xml\\' => array ( 0 => __DIR__ . '/..' . '/sabre/xml/lib', @@ -1597,6 +1602,15 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 'Sabre\\Xml\\Writer' => __DIR__ . '/..' . '/sabre/xml/lib/Writer.php', 'Sabre\\Xml\\XmlDeserializable' => __DIR__ . '/..' . '/sabre/xml/lib/XmlDeserializable.php', 'Sabre\\Xml\\XmlSerializable' => __DIR__ . '/..' . '/sabre/xml/lib/XmlSerializable.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\Completion' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/Completion.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\CompletionCommand' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/CompletionCommand.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\CompletionContext' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/CompletionContext.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\CompletionHandler' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/CompletionHandler.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\Completion\\CompletionAwareInterface' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/Completion/CompletionAwareInterface.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\Completion\\CompletionInterface' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/Completion/CompletionInterface.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\Completion\\ShellPathCompletion' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/Completion/ShellPathCompletion.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\EnvironmentCompletionContext' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/EnvironmentCompletionContext.php', + 'Stecman\\Component\\Symfony\\Console\\BashCompletion\\HookFactory' => __DIR__ . '/..' . '/stecman/symfony-console-completion/src/HookFactory.php', 'SuperClosure\\Analyzer\\AstAnalyzer' => __DIR__ . '/..' . '/jeremeamia/SuperClosure/src/Analyzer/AstAnalyzer.php', 'SuperClosure\\Analyzer\\ClosureAnalyzer' => __DIR__ . '/..' . '/jeremeamia/SuperClosure/src/Analyzer/ClosureAnalyzer.php', 'SuperClosure\\Analyzer\\Token' => __DIR__ . '/..' . '/jeremeamia/SuperClosure/src/Analyzer/Token.php', diff --git a/composer/installed.json b/composer/installed.json index e47abdaf7..42a82adae 100644 --- a/composer/installed.json +++ b/composer/installed.json @@ -2852,5 +2852,52 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com" + }, + { + "name": "stecman/symfony-console-completion", + "version": "0.7.0", + "version_normalized": "0.7.0.0", + "source": { + "type": "git", + "url": "https://github.com/stecman/symfony-console-completion.git", + "reference": "5461d43e53092b3d3b9dbd9d999f2054730f4bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stecman/symfony-console-completion/zipball/5461d43e53092b3d3b9dbd9d999f2054730f4bbb", + "reference": "5461d43e53092b3d3b9dbd9d999f2054730f4bbb", + "shasum": "" + }, + "require": { + "php": ">=5.3.2", + "symfony/console": "~2.3 || ~3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "time": "2016-02-24 05:08:54", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.6.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Stecman\\Component\\Symfony\\Console\\BashCompletion\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stephen Holdaway", + "email": "stephen@stecman.co.nz" + } + ], + "description": "Automatic BASH completion for Symfony Console Component based applications." } ] diff --git a/stecman/symfony-console-completion/LICENCE b/stecman/symfony-console-completion/LICENCE new file mode 100644 index 000000000..8f8e82c06 --- /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 000000000..f5adb45b9 --- /dev/null +++ b/stecman/symfony-console-completion/src/Completion.php @@ -0,0 +1,180 @@ +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 000000000..20963cb8c --- /dev/null +++ b/stecman/symfony-console-completion/src/Completion/CompletionAwareInterface.php @@ -0,0 +1,27 @@ +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 000000000..b51666a1d --- /dev/null +++ b/stecman/symfony-console-completion/src/CompletionCommand.php @@ -0,0 +1,144 @@ +setName('_completion') + ->setDefinition($this->createDefinition()) + ->setDescription('BASH completion hook.') + ->setHelp(<<eval `[program] _completion -g`. + +Or for an alias: + + eval `[program] _completion -g -p [alias]`. + +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(defaults to the absolute application path)." + ), + 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 000000000..602925343 --- /dev/null +++ b/stecman/symfony-console-completion/src/CompletionContext.php @@ -0,0 +1,256 @@ +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 000000000..531fae339 --- /dev/null +++ b/stecman/symfony-console-completion/src/CompletionHandler.php @@ -0,0 +1,445 @@ +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 000000000..04027ce1f --- /dev/null +++ b/stecman/symfony-console-completion/src/EnvironmentCompletionContext.php @@ -0,0 +1,46 @@ +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 000000000..19601e8b3 --- /dev/null +++ b/stecman/symfony-console-completion/src/HookFactory.php @@ -0,0 +1,207 @@ + <<<'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%% &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); + } +}