├── .gitignore ├── tests ├── bootstrap.php └── Stecman │ └── Component │ └── Symfony │ └── Console │ └── BashCompletion │ ├── Fixtures │ ├── HiddenCommand.php │ ├── TestBasicCommand.php │ ├── TestSymfonyStyleCommand.php │ └── CompletionAwareCommand.php │ ├── CompletionCommandTest.php │ ├── Common │ └── CompletionHandlerTestCase.php │ ├── HookFactoryTest.php │ ├── CompletionTest.php │ ├── CompletionContextTest.php │ └── CompletionHandlerTest.php ├── .github ├── dependabot.yml └── workflows │ └── phpunit.yml ├── src ├── Completion │ ├── CompletionAwareInterface.php │ ├── ShellPathCompletion.php │ └── CompletionInterface.php ├── EnvironmentCompletionContext.php ├── Completion.php ├── HookFactory.php ├── CompletionCommand.php ├── CompletionContext.php └── CompletionHandler.php ├── composer.json ├── phpunit.xml.dist ├── LICENCE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .idea 3 | /build/ 4 | phpunit.xml 5 | /composer.lock 6 | /.phpunit.result.cache 7 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | setName('internals') 10 | ->setHidden(true); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Completion/CompletionAwareInterface.php: -------------------------------------------------------------------------------- 1 | =8.1", 13 | "symfony/console": "~6.4 || ^7.1 || ^8.0" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "^9.5", 17 | "dms/phpunit-arraysubset-asserts": "^0.4.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Stecman\\Component\\Symfony\\Console\\BashCompletion\\": "src/" 22 | } 23 | }, 24 | "extra": { 25 | "branch-alias": { 26 | "dev-master": "0.14.x-dev" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/TestBasicCommand.php: -------------------------------------------------------------------------------- 1 | setName('wave') 12 | ->addOption( 13 | 'vigorous' 14 | ) 15 | ->addOption( 16 | 'jazz-hands', 17 | 'j' 18 | ) 19 | ->addOption( 20 | 'style', 21 | 's', 22 | InputOption::VALUE_REQUIRED 23 | ) 24 | ->addArgument( 25 | 'target', 26 | InputArgument::REQUIRED 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/TestSymfonyStyleCommand.php: -------------------------------------------------------------------------------- 1 | setName('walk:north') 11 | ->addOption( 12 | 'power', 13 | 'p' 14 | ) 15 | ->addOption( 16 | 'deploy:jazz-hands', 17 | 'j' 18 | ) 19 | ->addOption( 20 | 'style', 21 | 's', 22 | InputOption::VALUE_REQUIRED 23 | ) 24 | ->addOption( 25 | 'target', 26 | 't', 27 | InputOption::VALUE_REQUIRED 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | src 17 | 18 | 19 | 20 | 21 | ./tests 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Stephen Holdaway 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | contents: read 7 | 8 | concurrency: 9 | group: phpunit-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | phpunit: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | php-versions: ['8.1', '8.2', '8.3', '8.4', '8.5'] 20 | 21 | name: PHP ${{ matrix.php-versions }} 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | 27 | - name: Set up php ${{ matrix.php-versions }} 28 | uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 29 | with: 30 | php-version: ${{ matrix.php-versions }} 31 | coverage: none 32 | ini-file: development 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Set up dependencies 37 | run: composer i 38 | 39 | - name: PHPUnit with ZSH 40 | run: SHELL=zsh vendor/bin/phpunit 41 | 42 | - name: PHPUnit with BASH 43 | run: SHELL=bash vendor/bin/phpunit 44 | 45 | summary: 46 | permissions: 47 | contents: none 48 | runs-on: ubuntu-latest 49 | needs: [phpunit] 50 | 51 | if: always() 52 | 53 | name: phpunit-summary 54 | 55 | steps: 56 | - name: Summary status 57 | run: if ${{ needs.phpunit.result != 'success' }}; then exit 1; fi 58 | -------------------------------------------------------------------------------- /src/Completion/ShellPathCompletion.php: -------------------------------------------------------------------------------- 1 | commandName = $commandName; 28 | $this->targetName = $targetName; 29 | $this->type = $type; 30 | } 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function getType() 36 | { 37 | return $this->type; 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | public function getCommandName() 44 | { 45 | return $this->commandName; 46 | } 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | public function getTargetName() 52 | { 53 | return $this->targetName; 54 | } 55 | 56 | /** 57 | * Exit with a status code configured to defer completion to the shell 58 | * 59 | * @see \Stecman\Component\Symfony\Console\BashCompletion\HookFactory::$hooks 60 | */ 61 | public function run() 62 | { 63 | exit(self::PATH_COMPLETION_EXIT_CODE); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Completion/CompletionInterface.php: -------------------------------------------------------------------------------- 1 | commandLine = getenv('CMDLINE_CONTENTS'); 14 | $this->charIndex = intval(getenv('CMDLINE_CURSOR_INDEX')); 15 | 16 | if ($this->commandLine === false) { 17 | $message = 'Failed to configure from environment; Environment var CMDLINE_CONTENTS not set.'; 18 | 19 | if (getenv('COMP_LINE')) { 20 | $message .= "\n\nYou appear to be attempting completion using an out-dated hook. If you've just updated," 21 | . " you probably need to reinitialise the completion shell hook by reloading your shell" 22 | . " profile or starting a new shell session. If you are using a hard-coded (rather than generated)" 23 | . " hook, you will need to update that function with the new environment variable names." 24 | . "\n\nSee here for details: https://github.com/stecman/symfony-console-completion/issues/31"; 25 | } 26 | 27 | throw new \RuntimeException($message); 28 | } 29 | } 30 | 31 | /** 32 | * Use the word break characters set by the parent shell. 33 | * 34 | * @throws \RuntimeException 35 | */ 36 | public function useWordBreaksFromEnvironment() 37 | { 38 | $breaks = getenv('CMDLINE_WORDBREAKS'); 39 | 40 | if (!$breaks) { 41 | throw new \RuntimeException('Failed to read word breaks from environment; Environment var CMDLINE_WORDBREAKS not set'); 42 | } 43 | 44 | $this->wordBreaks = $breaks; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionCommandTest.php: -------------------------------------------------------------------------------- 1 | expectNotToPerformAssertions(); 20 | 21 | $app = new Application('Base application'); 22 | 23 | // Conflicting option shortcut 24 | $app->getDefinition()->addOption( 25 | new InputOption('conflicting-shortcut', 'g', InputOption::VALUE_NONE) 26 | ); 27 | 28 | // Conflicting option name 29 | $app->getDefinition()->addOption( 30 | new InputOption('program', null, InputOption::VALUE_REQUIRED) 31 | ); 32 | 33 | // Added in symfony 7.4 34 | if (method_exists($app, 'addCommand')) { 35 | $app->addCommand(new CompletionCommand()); 36 | } else { 37 | $app->add(new CompletionCommand()); 38 | } 39 | 40 | // Check completion command doesn't throw 41 | $app->doRun(new StringInput('_completion -g --program foo'), new NullOutput()); 42 | $app->doRun(new StringInput('_completion --help'), new NullOutput()); 43 | $app->doRun(new StringInput('help _completion'), new NullOutput()); 44 | 45 | // Check default options are available 46 | $app->doRun(new StringInput('_completion -V -vv --no-ansi --quiet'), new NullOutput()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/CompletionAwareCommand.php: -------------------------------------------------------------------------------- 1 | setName('completion-aware') 15 | ->addOption('option-with-suggestions', null, InputOption::VALUE_REQUIRED) 16 | ->addOption('option-without-suggestions', null, InputOption::VALUE_REQUIRED) 17 | ->addArgument('argument-without-suggestions') 18 | ->addArgument('argument-with-suggestions') 19 | ->addArgument('array-argument-with-suggestions', InputArgument::IS_ARRAY) 20 | ; 21 | } 22 | 23 | /** 24 | * Returns possible option values. 25 | * 26 | * @param string $optionName Option name. 27 | * @param CompletionContext $context Completion context. 28 | * 29 | * @return array 30 | */ 31 | public function completeOptionValues($optionName, CompletionContext $context) 32 | { 33 | if ($optionName === 'option-with-suggestions') { 34 | $suggestions = array('one-opt', 'two-opt'); 35 | 36 | if ('one' === $context->getCurrentWord()) { 37 | $suggestions[] = 'one-opt-context'; 38 | } 39 | 40 | return $suggestions; 41 | } 42 | 43 | return array(); 44 | } 45 | 46 | /** 47 | * Returns possible argument values. 48 | * 49 | * @param string $argumentName Argument name. 50 | * @param CompletionContext $context Completion context. 51 | * 52 | * @return array 53 | */ 54 | public function completeArgumentValues($argumentName, CompletionContext $context) 55 | { 56 | if (in_array($argumentName, array('argument-with-suggestions', 'array-argument-with-suggestions'))) { 57 | $suggestions = array('one-arg', 'two-arg'); 58 | 59 | if ('one' === $context->getCurrentWord()) { 60 | $suggestions[] = 'one-arg-context'; 61 | } 62 | 63 | return $suggestions; 64 | } 65 | 66 | return array(); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/Common/CompletionHandlerTestCase.php: -------------------------------------------------------------------------------- 1 | application = new Application('Base application'); 35 | $this->application->addCommands(array( 36 | new \CompletionAwareCommand(), 37 | new \TestBasicCommand(), 38 | new \TestSymfonyStyleCommand() 39 | )); 40 | 41 | if (method_exists('\HiddenCommand', 'setHidden')) { 42 | // Added in symfony 7.4 43 | if (method_exists($this->application, 'addCommand')) { 44 | $this->application->addCommand(new \HiddenCommand()); 45 | } else { 46 | $this->application->add(new \HiddenCommand()); 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * Create a handler set up with the given commandline and cursor position 53 | * 54 | * @param $commandLine 55 | * @param ?int $cursorIndex 56 | * @return CompletionHandler 57 | */ 58 | protected function createHandler($commandLine, $cursorIndex = null) 59 | { 60 | $context = new CompletionContext(); 61 | $context->setCommandLine($commandLine); 62 | $context->setCharIndex($cursorIndex === null ? strlen($commandLine) : $cursorIndex); 63 | 64 | return new CompletionHandler($this->application, $context); 65 | } 66 | 67 | /** 68 | * Get the list of terms from the output of CompletionHandler 69 | * The array index needs to be reset so that PHPUnit's array equality assertions match correctly. 70 | * 71 | * @param string $handlerOutput 72 | * @return string[] 73 | */ 74 | protected function getTerms($handlerOutput) 75 | { 76 | return array_values($handlerOutput); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Completion.php: -------------------------------------------------------------------------------- 1 | commandName = $commandName; 65 | $this->targetName = $targetName; 66 | $this->type = $type; 67 | $this->completion = $completion; 68 | } 69 | 70 | /** 71 | * Return the stored completion, or the results returned from the completion callback 72 | * 73 | * @return array 74 | */ 75 | public function run() 76 | { 77 | if ($this->isCallable()) { 78 | return call_user_func($this->completion); 79 | } 80 | 81 | return $this->completion; 82 | } 83 | 84 | /** 85 | * Get type of input (option/argument) the completion should be run for 86 | * 87 | * @see CompletionInterface::ALL_TYPES 88 | * @return string|null 89 | */ 90 | public function getType() 91 | { 92 | return $this->type; 93 | } 94 | 95 | /** 96 | * Set type of input (option/argument) the completion should be run for 97 | * 98 | * @see CompletionInterface::ALL_TYPES 99 | * @param string|null $type 100 | */ 101 | public function setType($type) 102 | { 103 | $this->type = $type; 104 | } 105 | 106 | /** 107 | * Get the command name the completion should be run for 108 | * 109 | * @see CompletionInterface::ALL_COMMANDS 110 | * @return string|null 111 | */ 112 | public function getCommandName() 113 | { 114 | return $this->commandName; 115 | } 116 | 117 | /** 118 | * Set the command name the completion should be run for 119 | * 120 | * @see CompletionInterface::ALL_COMMANDS 121 | * @param string|null $commandName 122 | */ 123 | public function setCommandName($commandName) 124 | { 125 | $this->commandName = $commandName; 126 | } 127 | 128 | /** 129 | * Set the option/argument name the completion should be run for 130 | * 131 | * @see setType() 132 | * @return string 133 | */ 134 | public function getTargetName() 135 | { 136 | return $this->targetName; 137 | } 138 | 139 | /** 140 | * Get the option/argument name the completion should be run for 141 | * 142 | * @see getType() 143 | * @param string $targetName 144 | */ 145 | public function setTargetName($targetName) 146 | { 147 | $this->targetName = $targetName; 148 | } 149 | 150 | /** 151 | * Return the array or callback configured for for the Completion 152 | * 153 | * @return array|callable 154 | */ 155 | public function getCompletion() 156 | { 157 | return $this->completion; 158 | } 159 | 160 | /** 161 | * Set the array or callback to return/run when Completion is run 162 | * 163 | * @see run() 164 | * @param array|callable $completion 165 | */ 166 | public function setCompletion($completion) 167 | { 168 | $this->completion = $completion; 169 | } 170 | 171 | /** 172 | * Check if the configured completion value is a callback function 173 | * 174 | * @return bool 175 | */ 176 | public function isCallable() 177 | { 178 | return is_callable($this->completion); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/HookFactoryTest.php: -------------------------------------------------------------------------------- 1 | factory = new HookFactory(); 19 | } 20 | 21 | /** 22 | * @dataProvider generateHookDataProvider 23 | */ 24 | public function testBashSyntax($programPath, $programName, $multiple) 25 | { 26 | if ($this->hasProgram('bash')) { 27 | $script = $this->factory->generateHook('bash', $programPath, $programName, $multiple); 28 | $this->assertSyntaxIsValid($script, 'bash -n', 'BASH hook'); 29 | } else { 30 | $this->markTestSkipped("Couldn't detect BASH program to run hook syntax check"); 31 | } 32 | } 33 | 34 | /** 35 | * @dataProvider generateHookDataProvider 36 | */ 37 | public function testZshSyntax($programPath, $programName, $multiple) 38 | { 39 | if ($this->hasProgram('zsh')) { 40 | $script = $this->factory->generateHook('zsh', $programPath, $programName, $multiple); 41 | $this->assertSyntaxIsValid($script, 'zsh -n', 'ZSH hook'); 42 | } else { 43 | $this->markTestSkipped("Couldn't detect ZSH program to run hook syntax check"); 44 | } 45 | } 46 | 47 | public static function generateHookDataProvider() 48 | { 49 | return array( 50 | array('/path/to/myprogram', null, false), 51 | array('/path/to/myprogram', null, true), 52 | array('/path/to/myprogram', 'myprogram', false), 53 | array('/path/to/myprogram', 'myprogram', true), 54 | array('/path/to/my-program', 'my-program', false) 55 | ); 56 | } 57 | 58 | public function testForMissingSemiColons() 59 | { 60 | $this->expectNotToPerformAssertions(); 61 | 62 | $class = new \ReflectionClass('Stecman\Component\Symfony\Console\BashCompletion\HookFactory'); 63 | $properties = $class->getStaticProperties(); 64 | $hooks = $properties['hooks']; 65 | 66 | // Check each line is commented or closed correctly to be collapsed for eval 67 | foreach ($hooks as $shellType => $hook) { 68 | $line = strtok($hook, "\n"); 69 | $lineNumber = 0; 70 | 71 | while ($line !== false) { 72 | $lineNumber++; 73 | 74 | if (!$this->isScriptLineValid($line)) { 75 | $this->fail("$shellType hook appears to be missing a semicolon on line $lineNumber:\n> $line"); 76 | } 77 | 78 | $line = strtok("\n"); 79 | } 80 | } 81 | } 82 | 83 | /** 84 | * Check if a line of shell script is safe to be collapsed to one line for eval 85 | */ 86 | protected function isScriptLineValid($line) 87 | { 88 | if (preg_match('/^\s*#/', $line)) { 89 | // Line is commented out 90 | return true; 91 | } 92 | 93 | if (preg_match('/[;\{\}]\s*$/', $line)) { 94 | // Line correctly ends with a semicolon or syntax 95 | return true; 96 | } 97 | 98 | if (preg_match(' 99 | /( 100 | ;\s*then | 101 | \s*else 102 | ) 103 | \s*$ 104 | /x', $line) 105 | ) { 106 | // Line ends with another permitted sequence 107 | return true; 108 | } 109 | 110 | return false; 111 | } 112 | 113 | protected function hasProgram($programName) 114 | { 115 | exec(sprintf( 116 | 'command -v %s', 117 | escapeshellarg($programName) 118 | ), $output, $return); 119 | 120 | return $return === 0; 121 | } 122 | 123 | /** 124 | * @param string $code - code to pipe to the syntax checking command 125 | * @param string $syntaxCheckCommand - equivalent to `bash -n`. 126 | * @param string $context - what the syntax check is for 127 | */ 128 | protected function assertSyntaxIsValid($code, $syntaxCheckCommand, $context) 129 | { 130 | $process = proc_open( 131 | escapeshellcmd($syntaxCheckCommand), 132 | array( 133 | 0 => array('pipe', 'r'), 134 | 1 => array('pipe', 'w'), 135 | 2 => array('pipe', 'w') 136 | ), 137 | $pipes 138 | ); 139 | 140 | if (is_resource($process)) { 141 | // Push code into STDIN for the syntax checking process 142 | fwrite($pipes[0], $code); 143 | fclose($pipes[0]); 144 | 145 | $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); 146 | fclose($pipes[1]); 147 | fclose($pipes[2]); 148 | 149 | $status = proc_close($process); 150 | 151 | $this->assertSame(0, $status, "Syntax check for $context failed:\n$output"); 152 | } else { 153 | throw new \RuntimeException("Failed to start process with command '$syntaxCheckCommand'"); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionTest.php: -------------------------------------------------------------------------------- 1 | $result) { 22 | $handler = $this->createHandler($commandLine); 23 | $handler->addHandlers($completions); 24 | $this->assertEquals($result, $this->getTerms($handler->runCompletion())); 25 | } 26 | } 27 | 28 | public function getCompletionTestInput() 29 | { 30 | $options = array('smooth', 'latin', 'moody'); 31 | 32 | return array( 33 | 'command match' => array( 34 | new Completion( 35 | 'wave', 36 | 'target', 37 | Completion::ALL_TYPES, 38 | $options 39 | ), 40 | array( 41 | 'app walk:north --target ' => array(), 42 | 'app wave ' => $options 43 | ) 44 | ), 45 | 46 | 'type restriction option' => array( 47 | new Completion( 48 | Completion::ALL_COMMANDS, 49 | 'target', 50 | Completion::TYPE_OPTION, 51 | $options 52 | ), 53 | array( 54 | 'app walk:north --target ' => $options, 55 | 'app wave ' => array() 56 | ) 57 | ), 58 | 59 | 'type restriction argument' => array( 60 | new Completion( 61 | Completion::ALL_COMMANDS, 62 | 'target', 63 | Completion::TYPE_ARGUMENT, 64 | $options 65 | ), 66 | array( 67 | 'app walk:north --target ' => array(), 68 | 'app wave ' => $options 69 | ) 70 | ), 71 | 72 | 'makeGlobalHandler static' => array( 73 | Completion::makeGlobalHandler( 74 | 'target', 75 | Completion::ALL_TYPES, 76 | $options 77 | ), 78 | array( 79 | 'app walk:north --target ' => $options, 80 | 'app wave ' => $options 81 | ) 82 | ), 83 | 84 | 'with anonymous function' => array( 85 | new Completion( 86 | 'wave', 87 | 'style', 88 | Completion::TYPE_OPTION, 89 | function() { 90 | return range(1, 5); 91 | } 92 | ), 93 | array( 94 | 'app walk:north --target ' => array(), 95 | 'app wave ' => array(), 96 | 'app wave --style ' => array(1, 2,3, 4, 5) 97 | ) 98 | ), 99 | 100 | 'with callable array' => array( 101 | new Completion( 102 | Completion::ALL_COMMANDS, 103 | 'target', 104 | Completion::ALL_TYPES, 105 | array($this, 'instanceMethodForCallableCheck') 106 | ), 107 | array( 108 | 'app walk:north --target ' => array('hello', 'world'), 109 | 'app wave ' => array('hello', 'world') 110 | ) 111 | ), 112 | 113 | 'multiple handlers' => array( 114 | array( 115 | new Completion( 116 | Completion::ALL_COMMANDS, 117 | 'target', 118 | Completion::TYPE_OPTION, 119 | array('all:option:target') 120 | ), 121 | new Completion( 122 | Completion::ALL_COMMANDS, 123 | 'target', 124 | Completion::ALL_TYPES, 125 | array('all:all:target') 126 | ), 127 | new Completion( 128 | Completion::ALL_COMMANDS, 129 | 'style', 130 | Completion::TYPE_OPTION, 131 | array('all:option:style') 132 | ), 133 | ), 134 | array( 135 | 'app walk:north ' => array(), 136 | 'app walk:north -t ' => array('all:option:target'), 137 | 'app wave ' => array('all:all:target'), 138 | 'app wave bruce -s ' => array('all:option:style'), 139 | 'app walk:north --style ' => array('all:option:style'), 140 | ) 141 | ) 142 | ); 143 | } 144 | 145 | /** 146 | * Used in the test "with callable array" 147 | * @return array 148 | */ 149 | public function instanceMethodForCallableCheck() 150 | { 151 | return array('hello', 'world'); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionContextTest.php: -------------------------------------------------------------------------------- 1 | setCommandLine('console config:application --direction="west" --with-bruce --repeat 3'); 16 | 17 | // Cursor at the end of the first word 18 | $context->setCharIndex(7); 19 | $words = $context->getWords(); 20 | 21 | $this->assertEquals(array( 22 | 'console', 23 | 'config:application', 24 | '--direction', 25 | 'west', 26 | '--with-bruce', 27 | '--repeat', 28 | '3' 29 | ), $words); 30 | } 31 | 32 | public function testCursorPosition() 33 | { 34 | $context = new CompletionContext(); 35 | $context->setCommandLine('make horse --legs 4 --colour black '); 36 | 37 | // Cursor at the start of the line 38 | $context->setCharIndex(0); 39 | $this->assertEquals(0, $context->getWordIndex()); 40 | 41 | // Cursor at the end of the line 42 | $context->setCharIndex(34); 43 | $this->assertEquals(5, $context->getWordIndex()); 44 | $this->assertEquals('black', $context->getCurrentWord()); 45 | 46 | // Cursor after space at the end of the string 47 | $context->setCharIndex(35); 48 | $this->assertEquals(6, $context->getWordIndex()); 49 | $this->assertEquals('', $context->getCurrentWord()); 50 | 51 | // Cursor in the middle of 'horse' 52 | $context->setCharIndex(8); 53 | $this->assertEquals(1, $context->getWordIndex()); 54 | $this->assertEquals('hor', $context->getCurrentWord()); 55 | 56 | // Cursor at the end of '--legs' 57 | $context->setCharIndex(17); 58 | $this->assertEquals(2, $context->getWordIndex()); 59 | $this->assertEquals('--legs', $context->getCurrentWord()); 60 | } 61 | 62 | public function testWordBreakingWithSmallInputs() 63 | { 64 | $context = new CompletionContext(); 65 | 66 | // Cursor at the end of a word and not in the following space has no effect 67 | $context->setCommandLine('cmd a'); 68 | $context->setCharIndex(5); 69 | $this->assertEquals(array('cmd', 'a'), $context->getWords()); 70 | $this->assertEquals(1, $context->getWordIndex()); 71 | $this->assertEquals('a', $context->getCurrentWord()); 72 | 73 | // As above, but in the middle of the command line string 74 | $context->setCommandLine('cmd a'); 75 | $context->setCharIndex(3); 76 | $this->assertEquals(array('cmd', 'a'), $context->getWords()); 77 | $this->assertEquals(0, $context->getWordIndex()); 78 | $this->assertEquals('cmd', $context->getCurrentWord()); 79 | 80 | // Cursor at the end of the command line with a space appends an empty word 81 | $context->setCommandLine('cmd a '); 82 | $context->setCharIndex(8); 83 | $this->assertEquals(array('cmd', 'a', ''), $context->getWords()); 84 | $this->assertEquals(2, $context->getWordIndex()); 85 | $this->assertEquals('', $context->getCurrentWord()); 86 | 87 | // Cursor in break space before a word appends an empty word in that position 88 | $context->setCommandLine('cmd a'); 89 | $context->setCharIndex(4); 90 | $this->assertEquals(array('cmd', '', 'a',), $context->getWords()); 91 | $this->assertEquals(1, $context->getWordIndex()); 92 | $this->assertEquals('', $context->getCurrentWord()); 93 | } 94 | 95 | public function testQuotedStringWordBreaking() 96 | { 97 | $context = new CompletionContext(); 98 | $context->setCharIndex(1000); 99 | $context->setCommandLine('make horse --legs=3 --name="Jeff the horse" --colour Extreme\\ Blanc \'foo " bar\''); 100 | 101 | // Ensure spaces and quotes are processed correctly 102 | $this->assertEquals( 103 | array( 104 | 'make', 105 | 'horse', 106 | '--legs', 107 | '3', 108 | '--name', 109 | 'Jeff the horse', 110 | '--colour', 111 | 'Extreme Blanc', 112 | 'foo " bar', 113 | '', 114 | ), 115 | $context->getWords() 116 | ); 117 | 118 | // Confirm the raw versions of the words are indexed correctly 119 | $this->assertEquals( 120 | array( 121 | 'make', 122 | 'horse', 123 | '--legs', 124 | '3', 125 | '--name', 126 | '"Jeff the horse"', 127 | '--colour', 128 | 'Extreme\\ Blanc', 129 | "'foo \" bar'", 130 | '', 131 | ), 132 | $context->getRawWords() 133 | ); 134 | 135 | $context = new CompletionContext(); 136 | $context->setCommandLine('console --tag='); 137 | 138 | // Cursor after equals symbol on option argument 139 | $context->setCharIndex(14); 140 | $this->assertEquals( 141 | array( 142 | 'console', 143 | '--tag', 144 | '' 145 | ), 146 | $context->getWords() 147 | ); 148 | } 149 | 150 | public function testGetRawCurrentWord() 151 | { 152 | $context = new CompletionContext(); 153 | 154 | $context->setCommandLine('cmd "double quoted" --option \'value\''); 155 | $context->setCharIndex(13); 156 | $this->assertEquals(1, $context->getWordIndex()); 157 | 158 | $this->assertEquals(array('cmd', '"double q', '--option', "'value'"), $context->getRawWords()); 159 | $this->assertEquals('"double q', $context->getRawCurrentWord()); 160 | } 161 | 162 | public function testConfigureFromEnvironment() 163 | { 164 | putenv("CMDLINE_CONTENTS=beam up li"); 165 | putenv('CMDLINE_CURSOR_INDEX=10'); 166 | 167 | $context = new EnvironmentCompletionContext(); 168 | 169 | $this->assertEquals( 170 | array( 171 | 'beam', 172 | 'up', 173 | 'li' 174 | ), 175 | $context->getWords() 176 | ); 177 | 178 | $this->assertEquals('li', $context->getCurrentWord()); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/HookFactory.php: -------------------------------------------------------------------------------- 1 | <<<'END' 31 | # BASH completion for %%program_path%% 32 | function %%function_name%% { 33 | 34 | # Copy BASH's completion variables to the ones the completion command expects 35 | # These line up exactly as the library was originally designed for BASH 36 | local CMDLINE_CONTENTS="$COMP_LINE"; 37 | local CMDLINE_CURSOR_INDEX="$COMP_POINT"; 38 | local CMDLINE_WORDBREAKS="$COMP_WORDBREAKS"; 39 | 40 | export CMDLINE_CONTENTS CMDLINE_CURSOR_INDEX CMDLINE_WORDBREAKS; 41 | 42 | local RESULT STATUS; 43 | 44 | # Force splitting by newline instead of default delimiters 45 | local IFS=$'\n'; 46 | 47 | RESULT="$(%%completion_command%% &2 echo "Completion was not registered for %%program_name%%:"; 81 | >&2 echo "The 'bash-completion' package is required but doesn't appear to be installed."; 82 | fi; 83 | END 84 | 85 | // ZSH Hook 86 | , 'zsh' => <<<'END' 87 | # ZSH completion for %%program_path%% 88 | function %%function_name%% { 89 | local -x CMDLINE_CONTENTS="$words"; 90 | local -x CMDLINE_CURSOR_INDEX; 91 | (( CMDLINE_CURSOR_INDEX = ${#${(j. .)words[1,CURRENT]}} )); 92 | 93 | local RESULT STATUS; 94 | RESULT=("${(@f)$( %%completion_command%% )}"); 95 | STATUS=$?; 96 | 97 | # Check if shell provided path completion is requested 98 | # @see Completion\ShellPathCompletion 99 | if [ $STATUS -eq 200 ]; then 100 | _path_files; 101 | return 0; 102 | 103 | # Bail out if PHP didn't exit cleanly 104 | elif [ $STATUS -ne 0 ]; then 105 | echo -e "$RESULT"; 106 | return $?; 107 | fi; 108 | 109 | compadd -- $RESULT; 110 | }; 111 | 112 | compdef %%function_name%% "%%program_name%%"; 113 | END 114 | ); 115 | 116 | /** 117 | * Return the names of shells that have hooks 118 | * 119 | * @return string[] 120 | */ 121 | public static function getShellTypes() 122 | { 123 | return array_keys(self::$hooks); 124 | } 125 | 126 | /** 127 | * Return a completion hook for the specified shell type 128 | * 129 | * @param string $type - a key from self::$hooks 130 | * @param string $programPath 131 | * @param ?string $programName 132 | * @param bool $multiple 133 | * 134 | * @return string 135 | */ 136 | public function generateHook($type, $programPath, $programName = null, $multiple = false) 137 | { 138 | if (!isset(self::$hooks[$type])) { 139 | throw new \RuntimeException(sprintf( 140 | "Cannot generate hook for unknown shell type '%s'. Available hooks are: %s", 141 | $type, 142 | implode(', ', self::getShellTypes()) 143 | )); 144 | } 145 | 146 | // Use the program path if an alias/name is not given 147 | $programName = $programName ?: $programPath; 148 | 149 | if ($multiple) { 150 | $completionCommand = '$1 _completion'; 151 | } else { 152 | $completionCommand = $programPath . ' _completion'; 153 | } 154 | 155 | // Pass shell type during completion so output can be encoded if the shell requires it 156 | $completionCommand .= " --shell-type $type"; 157 | 158 | return str_replace( 159 | array( 160 | '%%function_name%%', 161 | '%%program_name%%', 162 | '%%program_path%%', 163 | '%%completion_command%%', 164 | ), 165 | array( 166 | $this->generateFunctionName($programPath, $programName), 167 | $programName, 168 | $programPath, 169 | $completionCommand 170 | ), 171 | $this->stripComments(self::$hooks[$type]) 172 | ); 173 | } 174 | 175 | /** 176 | * Generate a function name that is unlikely to conflict with other generated function names in the same shell 177 | */ 178 | protected function generateFunctionName($programPath, $programName) 179 | { 180 | return sprintf( 181 | '_%s_%s_complete', 182 | $this->sanitiseForFunctionName(basename($programName)), 183 | substr(md5($programPath), 0, 16) 184 | ); 185 | } 186 | 187 | 188 | /** 189 | * Make a string safe for use as a shell function name 190 | * 191 | * @param string $name 192 | * @return string 193 | */ 194 | protected function sanitiseForFunctionName($name) 195 | { 196 | $name = str_replace('-', '_', $name); 197 | return preg_replace('/[^A-Za-z0-9_]+/', '', $name); 198 | } 199 | 200 | /** 201 | * Strip '#' style comments from a string 202 | * 203 | * BASH's eval doesn't work with comments as it removes line breaks, so comments have to be stripped out 204 | * for this method of sourcing the hook to work. Eval seems to be the most reliable method of getting a 205 | * hook into a shell, so while it would be nice to render comments, this stripping is required for now. 206 | * 207 | * @param string $script 208 | * @return string 209 | */ 210 | protected function stripComments($script) 211 | { 212 | return preg_replace('/(^\s*\#.*$)/m', '', $script); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionHandlerTest.php: -------------------------------------------------------------------------------- 1 | createHandler('app'); 15 | 16 | // It's not valid to complete the application name, so this should return nothing 17 | $this->assertEmpty($handler->runCompletion()); 18 | } 19 | 20 | public function testCompleteCommandNames() 21 | { 22 | $handler = $this->createHandler('app '); 23 | $this->assertEquals( 24 | array('help', 'list', 'completion', 'completion-aware', 'wave', 'walk:north'), 25 | $this->getTerms($handler->runCompletion()) 26 | ); 27 | } 28 | 29 | public function testCompleteCommandNameNonMatch() 30 | { 31 | $handler = $this->createHandler('app br'); 32 | $this->assertEmpty($handler->runCompletion()); 33 | } 34 | 35 | public function testCompleteCommandNamePartialTwoMatches() 36 | { 37 | $handler = $this->createHandler('app wa'); 38 | $this->assertEquals(array('wave', 'walk:north'), $this->getTerms($handler->runCompletion())); 39 | } 40 | 41 | public function testCompleteCommandNamePartialOneMatch() 42 | { 43 | $handler = $this->createHandler('app wav'); 44 | $this->assertEquals(array('wave'), $this->getTerms($handler->runCompletion())); 45 | } 46 | 47 | public function testCompleteCommandNameFull() 48 | { 49 | $handler = $this->createHandler('app wave'); 50 | 51 | // Completing on a matching word should return that word so that completion can continue 52 | $this->assertEquals(array('wave'), $this->getTerms($handler->runCompletion())); 53 | } 54 | 55 | public function testCompleteSingleDash() 56 | { 57 | $handler = $this->createHandler('app wave -'); 58 | 59 | // Short options are not given as suggestions 60 | $this->assertEmpty($handler->runCompletion()); 61 | } 62 | 63 | public function testCompleteOptionShortcut() 64 | { 65 | $handler = $this->createHandler('app wave -j'); 66 | 67 | // If a valid option shortcut is completed on, the shortcut is returned so that completion can continue 68 | $this->assertEquals(array('-j'), $this->getTerms($handler->runCompletion())); 69 | } 70 | 71 | public function testCompleteOptionShortcutFirst() 72 | { 73 | // Check command options complete 74 | $handler = $this->createHandler('app -v wave --'); 75 | $this->assertArraySubset(array('--vigorous', '--jazz-hands'), $this->getTerms($handler->runCompletion())); 76 | 77 | // Check unambiguous command name still completes 78 | $handler = $this->createHandler('app --quiet wav'); 79 | $this->assertEquals(array('wave'), $this->getTerms($handler->runCompletion())); 80 | } 81 | 82 | public function testCompleteDoubleDash() 83 | { 84 | $handler = $this->createHandler('app wave --'); 85 | $this->assertArraySubset(array('--vigorous', '--jazz-hands'), $this->getTerms($handler->runCompletion())); 86 | } 87 | 88 | public function testCompleteOptionFull() 89 | { 90 | $handler = $this->createHandler('app wave --jazz'); 91 | $this->assertArraySubset(array('--jazz-hands'), $this->getTerms($handler->runCompletion())); 92 | } 93 | 94 | public function testCompleteOptionEqualsValue() 95 | { 96 | // Cursor at the "=" sign 97 | $handler = $this->createHandler('app completion-aware --option-with-suggestions='); 98 | $this->assertEquals(array('one-opt', 'two-opt'), $this->getTerms($handler->runCompletion())); 99 | 100 | // Cursor at an opening quote 101 | $handler = $this->createHandler('app completion-aware --option-with-suggestions="'); 102 | $this->assertEquals(array('one-opt', 'two-opt'), $this->getTerms($handler->runCompletion())); 103 | 104 | // Cursor inside a quote with value 105 | $handler = $this->createHandler('app completion-aware --option-with-suggestions="two'); 106 | $this->assertEquals(array('two-opt'), $this->getTerms($handler->runCompletion())); 107 | } 108 | 109 | public function testCompleteOptionOrder() 110 | { 111 | // Completion of options should be able to happen anywhere after the command name 112 | $handler = $this->createHandler('app wave bruce --vi'); 113 | $this->assertEquals(array('--vigorous'), $this->getTerms($handler->runCompletion())); 114 | 115 | // Completing an option mid-commandline should work as normal 116 | $handler = $this->createHandler('app wave --vi --jazz-hands bruce', 13); 117 | $this->assertEquals(array('--vigorous'), $this->getTerms($handler->runCompletion())); 118 | } 119 | 120 | public function testCompleteColonCommand() 121 | { 122 | // Normal bash behaviour is to count the colon character as a word break 123 | // Since a colon is used to namespace Symfony Framework console commands the 124 | // character in a command name should not be taken as a word break 125 | // 126 | // @see https://github.com/stecman/symfony-console-completion/pull/1 127 | $handler = $this->createHandler('app walk'); 128 | $this->assertEquals(array('walk:north'), $this->getTerms($handler->runCompletion())); 129 | 130 | $handler = $this->createHandler('app walk:north'); 131 | $this->assertEquals(array('walk:north'), $this->getTerms($handler->runCompletion())); 132 | 133 | $handler = $this->createHandler('app walk:north --deploy'); 134 | $this->assertEquals(array('--deploy:jazz-hands'), $this->getTerms($handler->runCompletion())); 135 | } 136 | 137 | /** 138 | * @dataProvider completionAwareCommandDataProvider 139 | */ 140 | public function testCompletionAwareCommand($commandLine, array $suggestions) 141 | { 142 | $handler = $this->createHandler($commandLine); 143 | $this->assertSame($suggestions, $this->getTerms($handler->runCompletion())); 144 | } 145 | 146 | public static function completionAwareCommandDataProvider(): array 147 | { 148 | return array( 149 | 'not complete aware command' => array('app wave --vigorous ', array()), 150 | 'argument suggestions' => array('app completion-aware any-arg ', array('one-arg', 'two-arg')), 151 | 'argument no suggestions' => array('app completion-aware ', array()), 152 | 'argument suggestions + context' => array('app completion-aware any-arg one', array('one-arg', 'one-arg-context')), 153 | 'array argument suggestions' => array('app completion-aware any-arg one-arg array-arg1 ', array('one-arg', 'two-arg')), 154 | 'array argument suggestions + context' => array('app completion-aware any-arg one-arg array-arg1 one', array('one-arg', 'one-arg-context')), 155 | 'option suggestions' => array('app completion-aware --option-with-suggestions ', array('one-opt', 'two-opt')), 156 | 'option no suggestions' => array('app completion-aware --option-without-suggestions ', array()), 157 | 'option suggestions + context' => array( 158 | 'app completion-aware --option-with-suggestions one', array('one-opt', 'one-opt-context') 159 | ), 160 | ); 161 | } 162 | 163 | public function testShortCommandMatched() 164 | { 165 | $handler = $this->createHandler('app w:n --deploy'); 166 | $this->assertEquals(array('--deploy:jazz-hands'), $this->getTerms($handler->runCompletion())); 167 | } 168 | 169 | public function testShortCommandNotMatched() 170 | { 171 | $handler = $this->createHandler('app w --deploy'); 172 | $this->assertEquals(array(), $this->getTerms($handler->runCompletion())); 173 | } 174 | 175 | public function testHelpCommandCompletion() 176 | { 177 | $handler = $this->createHandler('app help '); 178 | $this->assertEquals( 179 | array('help', 'list', 'completion', 'completion-aware', 'wave', 'walk:north'), 180 | $this->getTerms($handler->runCompletion()) 181 | ); 182 | } 183 | 184 | public function testListCommandCompletion() 185 | { 186 | $handler = $this->createHandler('app list '); 187 | $this->assertEquals( 188 | array('walk'), 189 | $this->getTerms($handler->runCompletion()) 190 | ); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/CompletionCommand.php: -------------------------------------------------------------------------------- 1 | setName('_completion') 22 | ->setDefinition($this->createDefinition()) 23 | ->setDescription('BASH completion hook.') 24 | ->setHelp(<<eval `[program] _completion -g`. 28 | 29 | Or for an alias: 30 | 31 | eval `[program] _completion -g -p [alias]`. 32 | 33 | END 34 | ); 35 | 36 | // Hide this command from listing if supported 37 | $this->setHidden(true); 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function getNativeDefinition(): InputDefinition 44 | { 45 | return $this->createDefinition(); 46 | } 47 | 48 | /** 49 | * Ignore user-defined global options 50 | * 51 | * Any global options defined by user-code are meaningless to this command. 52 | * Options outside of the core defaults are ignored to avoid name and shortcut conflicts. 53 | */ 54 | public function mergeApplicationDefinition(bool $mergeArgs = true): void 55 | { 56 | // Get current application options 57 | $appDefinition = $this->getApplication()->getDefinition(); 58 | $originalOptions = $appDefinition->getOptions(); 59 | 60 | // Temporarily replace application options with a filtered list 61 | $appDefinition->setOptions( 62 | $this->filterApplicationOptions($originalOptions) 63 | ); 64 | 65 | parent::mergeApplicationDefinition($mergeArgs); 66 | 67 | // Restore original application options 68 | $appDefinition->setOptions($originalOptions); 69 | } 70 | 71 | /** 72 | * Reduce the passed list of options to the core defaults (if they exist) 73 | * 74 | * @param InputOption[] $appOptions 75 | * @return InputOption[] 76 | */ 77 | protected function filterApplicationOptions(array $appOptions) 78 | { 79 | return array_filter($appOptions, function(InputOption $option) { 80 | static $coreOptions = array( 81 | 'help' => true, 82 | 'quiet' => true, 83 | 'verbose' => true, 84 | 'version' => true, 85 | 'ansi' => true, 86 | 'no-ansi' => true, 87 | 'no-interaction' => true, 88 | ); 89 | 90 | return isset($coreOptions[$option->getName()]); 91 | }); 92 | } 93 | 94 | protected function execute(InputInterface $input, OutputInterface $output): int 95 | { 96 | $this->handler = new CompletionHandler($this->getApplication()); 97 | $handler = $this->handler; 98 | 99 | if ($input->getOption('generate-hook')) { 100 | global $argv; 101 | $program = $argv[0]; 102 | 103 | $factory = new HookFactory(); 104 | $alias = $input->getOption('program'); 105 | $multiple = (bool)$input->getOption('multiple'); 106 | 107 | if (!$alias) { 108 | $alias = basename($program); 109 | } 110 | 111 | $hook = $factory->generateHook( 112 | $input->getOption('shell-type') ?: $this->getShellType(), 113 | $program, 114 | $alias, 115 | $multiple 116 | ); 117 | 118 | $output->write($hook, true); 119 | } else { 120 | $handler->setContext(new EnvironmentCompletionContext()); 121 | 122 | // Get completion results 123 | $results = $this->runCompletion(); 124 | 125 | // Escape results for the current shell 126 | $shellType = $input->getOption('shell-type') ?: $this->getShellType(); 127 | 128 | foreach ($results as &$result) { 129 | $result = $this->escapeForShell($result, $shellType); 130 | } 131 | 132 | $output->write($results, true); 133 | } 134 | 135 | return SymfonyCommand::SUCCESS; 136 | } 137 | 138 | /** 139 | * Escape each completion result for the specified shell 140 | * 141 | * @param string $result - Completion results that should appear in the shell 142 | * @param string $shellType - Valid shell type from HookFactory 143 | * @return string 144 | */ 145 | protected function escapeForShell($result, $shellType) 146 | { 147 | switch ($shellType) { 148 | // BASH requires special escaping for multi-word and special character results 149 | // This emulates registering completion with`-o filenames`, without side-effects like dir name slashes 150 | case 'bash': 151 | $context = $this->handler->getContext(); 152 | $wordStart = substr($context->getRawCurrentWord(), 0, 1); 153 | 154 | if ($wordStart == "'") { 155 | // If the current word is single-quoted, escape any single quotes in the result 156 | $result = str_replace("'", "\\'", $result); 157 | } else if ($wordStart == '"') { 158 | // If the current word is double-quoted, escape any double quotes in the result 159 | $result = str_replace('"', '\\"', $result); 160 | } else { 161 | // Otherwise assume the string is unquoted and word breaks should be escaped 162 | $result = preg_replace('/([\s\'"\\\\])/', '\\\\$1', $result); 163 | } 164 | 165 | // Escape output to prevent special characters being lost when passing results to compgen 166 | return escapeshellarg($result); 167 | 168 | // No transformation by default 169 | default: 170 | return $result; 171 | } 172 | } 173 | 174 | /** 175 | * Run the completion handler and return a filtered list of results 176 | * 177 | * @deprecated - This will be removed in 1.0.0 in favour of CompletionCommand::configureCompletion 178 | * 179 | * @return string[] 180 | */ 181 | protected function runCompletion() 182 | { 183 | $this->configureCompletion($this->handler); 184 | return $this->handler->runCompletion(); 185 | } 186 | 187 | /** 188 | * Configure the CompletionHandler instance before it is run 189 | * 190 | * @param CompletionHandler $handler 191 | */ 192 | protected function configureCompletion(CompletionHandler $handler) 193 | { 194 | // Override this method to configure custom value completions 195 | } 196 | 197 | /** 198 | * Determine the shell type for use with HookFactory 199 | * 200 | * @return string 201 | */ 202 | protected function getShellType() 203 | { 204 | if (!getenv('SHELL')) { 205 | throw new \RuntimeException('Could not read SHELL environment variable. Please specify your shell type using the --shell-type option.'); 206 | } 207 | 208 | return basename(getenv('SHELL')); 209 | } 210 | 211 | protected function createDefinition() 212 | { 213 | return new InputDefinition(array( 214 | new InputOption( 215 | 'generate-hook', 216 | 'g', 217 | InputOption::VALUE_NONE, 218 | 'Generate BASH code that sets up completion for this application.' 219 | ), 220 | new InputOption( 221 | 'program', 222 | 'p', 223 | InputOption::VALUE_REQUIRED, 224 | "Program name that should trigger completion\n(defaults to the absolute application path)." 225 | ), 226 | new InputOption( 227 | 'multiple', 228 | 'm', 229 | InputOption::VALUE_NONE, 230 | "Generated hook can be used for multiple applications." 231 | ), 232 | new InputOption( 233 | 'shell-type', 234 | null, 235 | InputOption::VALUE_OPTIONAL, 236 | 'Set the shell type (zsh or bash). Otherwise this is determined automatically.' 237 | ), 238 | )); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BASH/ZSH auto-complete for Symfony Console applications 2 | 3 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/stecman/symfony-console-completion/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/stecman/symfony-console-completion/?branch=master) 4 | 5 | [![Latest Stable Version](https://poser.pugx.org/stecman/symfony-console-completion/v)](https://packagist.org/packages/stecman/symfony-console-completion) 6 | [![Total Downloads](https://poser.pugx.org/stecman/symfony-console-completion/downloads)](https://packagist.org/packages/stecman/symfony-console-completion) 7 | [![License](https://poser.pugx.org/stecman/symfony-console-completion/license)](https://packagist.org/packages/stecman/symfony-console-completion) 8 | [![PHP Version Require](https://poser.pugx.org/stecman/symfony-console-completion/require/php)](https://packagist.org/packages/stecman/symfony-console-completion) 9 | 10 | This package provides automatic (tab) completion in BASH and ZSH for Symfony Console Component based applications. With zero configuration, this package allows completion of available command names and the options they provide. User code can define custom completion behaviour for argument and option values. 11 | 12 | Example of zero-config use with Composer: 13 | 14 | ![Composer BASH completion](https://i.imgur.com/MoDWkby.gif) 15 | 16 | ## Zero-config use 17 | 18 | If you don't need any custom completion behaviour, you can simply add the completion command to your application: 19 | 20 | 1. Install `stecman/symfony-console-completion` using [composer](https://getcomposer.org/) by running: 21 | ``` 22 | $ composer require stecman/symfony-console-completion 23 | ``` 24 | 25 | 2. For standalone Symfony Console applications, add an instance of `CompletionCommand` to your application's `Application::getDefaultCommands()` method: 26 | 27 | ```php 28 | protected function getDefaultCommands() 29 | { 30 | //... 31 | $commands[] = new \Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand(); 32 | //... 33 | } 34 | ``` 35 | 36 | For Symfony Framework applications, register the `CompletionCommand` as a service in `app/config/services.yml`: 37 | 38 | ```yaml 39 | services: 40 | #... 41 | console.completion_command: 42 | class: Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand 43 | tags: 44 | - { name: console.command } 45 | #... 46 | ``` 47 | 48 | 3. Register completion for your application by running one of the following in a terminal, replacing `[program]` with the command you use to run your application (eg. 'composer'): 49 | 50 | ```bash 51 | # BASH ~4.x, ZSH 52 | source <([program] _completion --generate-hook) 53 | 54 | # BASH ~3.x, ZSH 55 | [program] _completion --generate-hook | source /dev/stdin 56 | 57 | # BASH (any version) 58 | eval $([program] _completion --generate-hook) 59 | ``` 60 | 61 | By default this registers completion for the absolute path to you application, which will work if the program is accessible on your PATH. You can specify a program name to complete for instead using the `--program` option, which is required if you're using an alias to run the program. 62 | 63 | 4. If you want the completion to apply automatically for all new shell sessions, add the command from step 3 to your shell's profile (eg. `~/.bash_profile` or `~/.zshrc`) 64 | 65 | Note: The type of shell (ZSH/BASH) is automatically detected using the `SHELL` environment variable at run time. In some circumstances, you may need to explicitly specify the shell type with the `--shell-type` option. 66 | 67 | The current version supports Symfony 6 and PHP 8.x only, due to backwards compatibility breaks in Symfony 6. For older versions of Symfony and PHP, use [version 0.11.0](https://github.com/stecman/symfony-console-completion/releases/tag/0.11.0). 68 | 69 | 70 | ## How it works 71 | 72 | The `--generate-hook` option of `CompletionCommand` generates a small shell script that registers a function with your shell's completion system to act as a bridge between the shell and the completion command in your application. When you request completion for your program (by pressing tab with your program name as the first word on the command line), the bridge function is run; passing the current command line contents and cursor position to `[program] _completion`, and feeding the resulting output back to the shell. 73 | 74 | 75 | ## Defining value completions 76 | 77 | By default, no completion results will be returned for option and argument values. There are two ways of defining custom completion values for values: extend `CompletionCommand`, or implement `CompletionAwareInterface`. 78 | 79 | ### Implementing `CompletionAwareInterface` 80 | 81 | `CompletionAwareInterface` allows a command to be responsible for completing its own option and argument values. When completion is run with a command name specified (eg. `myapp mycommand ...`) and the named command implements this interface, the appropriate interface method is called automatically: 82 | 83 | ```php 84 | class MyCommand extends Command implements CompletionAwareInterface 85 | { 86 | ... 87 | 88 | public function completeOptionValues($optionName, CompletionContext $context) 89 | { 90 | if ($optionName == 'some-option') { 91 | return ['myvalue', 'other-value', 'word']; 92 | } 93 | } 94 | 95 | public function completeArgumentValues($argumentName, CompletionContext $context) 96 | { 97 | if ($argumentName == 'package') { 98 | return $this->getPackageNamesFromDatabase($context->getCurrentWord()); 99 | } 100 | } 101 | } 102 | ``` 103 | 104 | This method of generating completions doesn't support use of `CompletionInterface` implementations at the moment, which make it easy to share completion behaviour between commands. To use this functionality, you'll need write your value completions by extending `CompletionCommand`. 105 | 106 | 107 | ### Extending `CompletionCommand` 108 | 109 | Argument and option value completions can also be defined by extending `CompletionCommand` and overriding the `configureCompletion` method: 110 | 111 | ```php 112 | class MyCompletionCommand extends CompletionCommand 113 | { 114 | protected function configureCompletion(CompletionHandler $handler) 115 | { 116 | $handler->addHandlers([ 117 | // Instances of Completion go here. 118 | // See below for examples. 119 | ]); 120 | } 121 | } 122 | ``` 123 | 124 | #### The `Completion` class 125 | 126 | The following snippets demonstrate how the `Completion` class works with `CompletionHandler`, and some possible configurations. The examples are for an application with the signature: 127 | 128 | `myapp (walk|run) [-w|--weather=""] direction` 129 | 130 | 131 | ##### Command-specific argument completion with an array 132 | 133 | ```php 134 | $handler->addHandler( 135 | new Completion( 136 | 'walk', // match command name 137 | 'direction', // match argument/option name 138 | Completion::TYPE_ARGUMENT, // match definition type (option/argument) 139 | [ // array or callback for results 140 | 'north', 141 | 'east', 142 | 'south', 143 | 'west' 144 | ] 145 | ) 146 | ); 147 | ``` 148 | 149 | This will complete the `direction` argument for this: 150 | 151 | ```bash 152 | $ myapp walk [tab] 153 | ``` 154 | 155 | but not this: 156 | 157 | ```bash 158 | $ myapp run [tab] 159 | ``` 160 | 161 | ##### Non-command-specific (global) argument completion with a function 162 | 163 | ```php 164 | $handler->addHandler( 165 | new Completion( 166 | Completion::ALL_COMMANDS, 167 | 'direction', 168 | Completion::TYPE_ARGUMENT, 169 | function() { 170 | return range(1, 10); 171 | } 172 | ) 173 | ); 174 | ``` 175 | 176 | This will complete the `direction` argument for both commands: 177 | 178 | ```bash 179 | $ myapp walk [tab] 180 | $ myapp run [tab] 181 | ``` 182 | 183 | ##### Option completion 184 | 185 | Option handlers work the same way as argument handlers, except you use `Completion::TYPE_OPTION` for the type. 186 | 187 | ```php 188 | $handler->addHandler( 189 | new Completion( 190 | Completion::ALL_COMMANDS, 191 | 'weather', 192 | Completion::TYPE_OPTION, 193 | [ 194 | 'raining', 195 | 'sunny', 196 | 'everything is on fire!' 197 | ] 198 | ) 199 | ); 200 | ``` 201 | 202 | ##### Completing the for both arguments and options 203 | 204 | To have a completion run for both options and arguments matching the specified name, you can use the type `Completion::ALL_TYPES`. Combining this with `Completion::ALL_COMMANDS` and consistent option/argument naming throughout your application, it's easy to share completion behaviour between commands, options and arguments: 205 | 206 | ```php 207 | $handler->addHandler( 208 | new Completion( 209 | Completion::ALL_COMMANDS, 210 | 'package', 211 | Completion::ALL_TYPES, 212 | function() { 213 | // ... 214 | } 215 | ) 216 | ); 217 | ``` 218 | 219 | ## Example completions 220 | 221 | ### Completing references from a Git repository 222 | 223 | ```php 224 | new Completion( 225 | Completion::ALL_COMMANDS, 226 | 'ref', 227 | Completion::TYPE_OPTION, 228 | function () { 229 | $raw = shell_exec('git show-ref --abbr'); 230 | if (preg_match_all('/refs\/(?:heads|tags)?\/?(.*)/', $raw, $matches)) { 231 | return $matches[1]; 232 | } 233 | } 234 | ) 235 | ``` 236 | 237 | ### Completing filesystem paths 238 | 239 | This library provides the completion implementation `ShellPathCompletion` which defers path completion to the shell's built-in path completion behaviour rather than implementing it in PHP, so that users get the path completion behaviour they expect from their shell. 240 | 241 | ```php 242 | new Completion\ShellPathCompletion( 243 | Completion::ALL_COMMANDS, 244 | 'path', 245 | Completion::TYPE_OPTION 246 | ) 247 | 248 | ``` 249 | 250 | ## Behaviour notes 251 | 252 | * Option shortcuts are not offered as completion options, however requesting completion (ie. pressing tab) on a valid option shortcut will complete. 253 | * Completion is not implemented for the `--option="value"` style of passing a value to an option, however `--option value` and `--option "value"` work and are functionally identical. 254 | * Value completion is always run for options marked as `InputOption::VALUE_OPTIONAL` since there is currently no way to determine the desired behaviour from the command line contents (ie. skip the optional value or complete for it) 255 | -------------------------------------------------------------------------------- /src/CompletionContext.php: -------------------------------------------------------------------------------- 1 | commandLine 27 | * 28 | * Bash equivalent: COMP_POINT 29 | * 30 | * @var int 31 | */ 32 | protected $charIndex = 0; 33 | 34 | /** 35 | * An array of the individual words in the current command line. 36 | * 37 | * This is not set until $this->splitCommand() is called, when it is populated by 38 | * $commandLine exploded by $wordBreaks 39 | * 40 | * Bash equivalent: COMP_WORDS 41 | * 42 | * @var string[]|null 43 | */ 44 | protected $words = null; 45 | 46 | /** 47 | * Words from the currently command-line before quotes and escaping is processed 48 | * 49 | * This is indexed the same as $this->words, but in their raw input terms are in their input form, including 50 | * quotes and escaping. 51 | * 52 | * @var string[]|null 53 | */ 54 | protected $rawWords = null; 55 | 56 | /** 57 | * The index in $this->words containing the word at the current cursor position. 58 | * 59 | * This is not set until $this->splitCommand() is called. 60 | * 61 | * Bash equivalent: COMP_CWORD 62 | * 63 | * @var int|null 64 | */ 65 | protected $wordIndex = null; 66 | 67 | /** 68 | * Characters that $this->commandLine should be split on to get a list of individual words 69 | * 70 | * Bash equivalent: COMP_WORDBREAKS 71 | * 72 | * @var string 73 | */ 74 | protected $wordBreaks = "= \t\n"; 75 | 76 | /** 77 | * Set the whole contents of the command line as a string 78 | * 79 | * @param string $commandLine 80 | */ 81 | public function setCommandLine($commandLine) 82 | { 83 | $this->commandLine = $commandLine; 84 | $this->reset(); 85 | } 86 | 87 | /** 88 | * Return the current command line verbatim as a string 89 | * 90 | * @return string 91 | */ 92 | public function getCommandLine() 93 | { 94 | return $this->commandLine; 95 | } 96 | 97 | /** 98 | * Return the word from the command line that the cursor is currently in 99 | * 100 | * Most of the time this will be a partial word. If the cursor has a space before it, 101 | * this will return an empty string, indicating a new word. 102 | * 103 | * @return string 104 | */ 105 | public function getCurrentWord() 106 | { 107 | if (isset($this->words[$this->wordIndex])) { 108 | return $this->words[$this->wordIndex]; 109 | } 110 | 111 | return ''; 112 | } 113 | 114 | /** 115 | * Return the unprocessed string for the word under the cursor 116 | * 117 | * This preserves any quotes and escaping that are present in the input command line. 118 | * 119 | * @return string 120 | */ 121 | public function getRawCurrentWord() 122 | { 123 | if (isset($this->rawWords[$this->wordIndex])) { 124 | return $this->rawWords[$this->wordIndex]; 125 | } 126 | 127 | return ''; 128 | } 129 | 130 | /** 131 | * Return a word by index from the command line 132 | * 133 | * @see $words, $wordBreaks 134 | * @param int $index 135 | * @return string 136 | */ 137 | public function getWordAtIndex($index) 138 | { 139 | if (isset($this->words[$index])) { 140 | return $this->words[$index]; 141 | } 142 | 143 | return ''; 144 | } 145 | 146 | /** 147 | * Get the contents of the command line, exploded into words based on the configured word break characters 148 | * 149 | * @see $wordBreaks, setWordBreaks 150 | * @return array 151 | */ 152 | public function getWords() 153 | { 154 | if ($this->words === null) { 155 | $this->splitCommand(); 156 | } 157 | 158 | return $this->words; 159 | } 160 | 161 | /** 162 | * Get the unprocessed/literal words from the command line 163 | * 164 | * This is indexed the same as getWords(), but preserves any quoting and escaping from the command line 165 | * 166 | * @return string[] 167 | */ 168 | public function getRawWords() 169 | { 170 | if ($this->rawWords === null) { 171 | $this->splitCommand(); 172 | } 173 | 174 | return $this->rawWords; 175 | } 176 | 177 | /** 178 | * Get the index of the word the cursor is currently in 179 | * 180 | * @see getWords, getCurrentWord 181 | * @return int 182 | */ 183 | public function getWordIndex() 184 | { 185 | if ($this->wordIndex === null) { 186 | $this->splitCommand(); 187 | } 188 | 189 | return $this->wordIndex; 190 | } 191 | 192 | /** 193 | * Get the character index of the user's cursor on the command line 194 | * 195 | * This is in the context of the full command line string, so includes word break characters. 196 | * Note that some shells can only provide an approximation for character index. Under ZSH for 197 | * example, this will always be the character at the start of the current word. 198 | * 199 | * @return int 200 | */ 201 | public function getCharIndex() 202 | { 203 | return $this->charIndex; 204 | } 205 | 206 | /** 207 | * Set the cursor position as a character index relative to the start of the command line 208 | * 209 | * @param int $index 210 | */ 211 | public function setCharIndex($index) 212 | { 213 | $this->charIndex = $index; 214 | $this->reset(); 215 | } 216 | 217 | /** 218 | * Set characters to use as split points when breaking the command line into words 219 | * 220 | * This defaults to a sane value based on BASH's word break characters and shouldn't 221 | * need to be changed unless your completions contain the default word break characters. 222 | * 223 | * @deprecated This is becoming an internal setting that doesn't make sense to expose publicly. 224 | * 225 | * @see wordBreaks 226 | * @param string $charList - a single string containing all of the characters to break words on 227 | */ 228 | public function setWordBreaks($charList) 229 | { 230 | // Drop quotes from break characters - strings are handled separately to word breaks now 231 | $this->wordBreaks = str_replace(array('"', '\''), '', $charList);; 232 | $this->reset(); 233 | } 234 | 235 | /** 236 | * Split the command line into words using the configured word break characters 237 | * 238 | * @return string[] 239 | */ 240 | protected function splitCommand() 241 | { 242 | $tokens = $this->tokenizeString($this->commandLine); 243 | 244 | foreach ($tokens as $token) { 245 | if ($token['type'] != 'break') { 246 | $this->words[] = $this->getTokenValue($token); 247 | $this->rawWords[] = $token['value']; 248 | } 249 | 250 | // Determine which word index the cursor is inside once we reach it's offset 251 | if ($this->wordIndex === null && $this->charIndex <= $token['offsetEnd']) { 252 | $this->wordIndex = count($this->words) - 1; 253 | 254 | if ($token['type'] == 'break') { 255 | // Cursor is in the break-space after a word 256 | // Push an empty word at the cursor to allow completion of new terms at the cursor, ignoring words ahead 257 | $this->wordIndex++; 258 | $this->words[] = ''; 259 | $this->rawWords[] = ''; 260 | continue; 261 | } 262 | 263 | if ($this->charIndex < $token['offsetEnd']) { 264 | // Cursor is inside the current word - truncate the word at the cursor to complete on 265 | // This emulates BASH completion's behaviour with COMP_CWORD 266 | 267 | // Create a copy of the token with its value truncated 268 | $truncatedToken = $token; 269 | $relativeOffset = $this->charIndex - $token['offset']; 270 | $truncatedToken['value'] = substr($token['value'], 0, $relativeOffset); 271 | 272 | // Replace the current word with the truncated value 273 | $this->words[$this->wordIndex] = $this->getTokenValue($truncatedToken); 274 | $this->rawWords[$this->wordIndex] = $truncatedToken['value']; 275 | } 276 | } 277 | } 278 | 279 | // Cursor position is past the end of the command line string - consider it a new word 280 | if ($this->wordIndex === null) { 281 | $this->wordIndex = count($this->words); 282 | $this->words[] = ''; 283 | $this->rawWords[] = ''; 284 | } 285 | } 286 | 287 | /** 288 | * Return a token's value with escaping and quotes removed 289 | * 290 | * @see self::tokenizeString() 291 | * @param array $token 292 | * @return string 293 | */ 294 | protected function getTokenValue($token) 295 | { 296 | $value = $token['value']; 297 | 298 | // Remove outer quote characters (or first quote if unclosed) 299 | if ($token['type'] == 'quoted') { 300 | $value = preg_replace('/^(?:[\'"])(.*?)(?:[\'"])?$/', '$1', $value); 301 | } 302 | 303 | // Remove escape characters 304 | $value = preg_replace('/\\\\(.)/', '$1', $value); 305 | 306 | return $value; 307 | } 308 | 309 | /** 310 | * Break a string into words, quoted strings and non-words (breaks) 311 | * 312 | * Returns an array of unmodified segments of $string with offset and type information. 313 | * 314 | * @param string $string 315 | * @return array as [ [type => string, value => string, offset => int], ... ] 316 | */ 317 | protected function tokenizeString($string) 318 | { 319 | // Map capture groups to returned token type 320 | $typeMap = array( 321 | 'double_quote_string' => 'quoted', 322 | 'single_quote_string' => 'quoted', 323 | 'word' => 'word', 324 | 'break' => 'break', 325 | ); 326 | 327 | // Escape every word break character including whitespace 328 | // preg_quote won't work here as it doesn't understand the ignore whitespace flag ("x") 329 | $breaks = preg_replace('/(.)/', '\\\$1', $this->wordBreaks); 330 | 331 | $pattern = <<<"REGEX" 332 | /(?: 333 | (?P 334 | "(\\\\.|[^\"\\\\])*(?:"|$) 335 | ) | 336 | (?P 337 | '(\\\\.|[^'\\\\])*(?:'|$) 338 | ) | 339 | (?P 340 | (?:\\\\.|[^$breaks])+ 341 | ) | 342 | (?P 343 | [$breaks]+ 344 | ) 345 | )/x 346 | REGEX; 347 | 348 | $tokens = array(); 349 | 350 | if (!preg_match_all($pattern, $string, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { 351 | return $tokens; 352 | } 353 | 354 | foreach ($matches as $set) { 355 | foreach ($set as $groupName => $match) { 356 | 357 | // Ignore integer indices preg_match outputs (duplicates of named groups) 358 | if (is_integer($groupName)) { 359 | continue; 360 | } 361 | 362 | // Skip if the offset indicates this group didn't match 363 | if ($match[1] === -1) { 364 | continue; 365 | } 366 | 367 | $tokens[] = array( 368 | 'type' => $typeMap[$groupName], 369 | 'value' => $match[0], 370 | 'offset' => $match[1], 371 | 'offsetEnd' => $match[1] + strlen($match[0]) 372 | ); 373 | 374 | // Move to the next set (only one group should match per set) 375 | continue; 376 | } 377 | } 378 | 379 | return $tokens; 380 | } 381 | 382 | /** 383 | * Reset the computed words so that $this->splitWords is forced to run again 384 | */ 385 | protected function reset() 386 | { 387 | $this->words = null; 388 | $this->wordIndex = null; 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/CompletionHandler.php: -------------------------------------------------------------------------------- 1 | application = $application; 46 | $this->context = $context; 47 | 48 | // Set up completions for commands that are built-into Application 49 | $this->addHandler( 50 | new Completion( 51 | 'help', 52 | 'command_name', 53 | Completion::TYPE_ARGUMENT, 54 | $this->getCommandNames() 55 | ) 56 | ); 57 | 58 | $this->addHandler( 59 | new Completion( 60 | 'list', 61 | 'namespace', 62 | Completion::TYPE_ARGUMENT, 63 | $application->getNamespaces() 64 | ) 65 | ); 66 | } 67 | 68 | public function setContext(CompletionContext $context) 69 | { 70 | $this->context = $context; 71 | } 72 | 73 | /** 74 | * @return CompletionContext 75 | */ 76 | public function getContext() 77 | { 78 | return $this->context; 79 | } 80 | 81 | /** 82 | * @param CompletionInterface[] $array 83 | */ 84 | public function addHandlers(array $array) 85 | { 86 | $this->helpers = array_merge($this->helpers, $array); 87 | } 88 | 89 | /** 90 | * @param CompletionInterface $helper 91 | */ 92 | public function addHandler(CompletionInterface $helper) 93 | { 94 | $this->helpers[] = $helper; 95 | } 96 | 97 | /** 98 | * Do the actual completion, returning an array of strings to provide to the parent shell's completion system 99 | * 100 | * @throws \RuntimeException 101 | * @return string[] 102 | */ 103 | public function runCompletion() 104 | { 105 | if (!$this->context) { 106 | throw new \RuntimeException('A CompletionContext must be set before requesting completion.'); 107 | } 108 | 109 | // Set the command to query options and arugments from 110 | $this->command = $this->detectCommand(); 111 | 112 | $process = array( 113 | 'completeForOptionValues', 114 | 'completeForOptionShortcuts', 115 | 'completeForOptionShortcutValues', 116 | 'completeForOptions', 117 | 'completeForCommandName', 118 | 'completeForCommandArguments' 119 | ); 120 | 121 | foreach ($process as $methodName) { 122 | $result = $this->{$methodName}(); 123 | 124 | if (false !== $result) { 125 | // Return the result of the first completion mode that matches 126 | return $this->filterResults((array) $result); 127 | } 128 | } 129 | 130 | return array(); 131 | } 132 | 133 | /** 134 | * Get an InputInterface representation of the completion context 135 | * 136 | * @deprecated Incorrectly uses the ArrayInput API and is no longer needed. 137 | * This will be removed in the next major version. 138 | * 139 | * @return ArrayInput 140 | */ 141 | public function getInput() 142 | { 143 | // Filter the command line content to suit ArrayInput 144 | $words = $this->context->getWords(); 145 | array_shift($words); 146 | $words = array_filter($words); 147 | 148 | return new ArrayInput($words); 149 | } 150 | 151 | /** 152 | * Attempt to complete the current word as a long-form option (--my-option) 153 | * 154 | * @return array|false 155 | */ 156 | protected function completeForOptions() 157 | { 158 | $word = $this->context->getCurrentWord(); 159 | 160 | if (substr($word, 0, 2) === '--') { 161 | $options = array(); 162 | 163 | foreach ($this->getAllOptions() as $opt) { 164 | $options[] = '--'.$opt->getName(); 165 | } 166 | 167 | return $options; 168 | } 169 | 170 | return false; 171 | } 172 | 173 | /** 174 | * Attempt to complete the current word as an option shortcut. 175 | * 176 | * If the shortcut exists it will be completed, but a list of possible shortcuts is never returned for completion. 177 | * 178 | * @return array|false 179 | */ 180 | protected function completeForOptionShortcuts() 181 | { 182 | $word = $this->context->getCurrentWord(); 183 | 184 | if (strpos($word, '-') === 0 && strlen($word) == 2) { 185 | $definition = $this->command ? $this->command->getNativeDefinition() : $this->application->getDefinition(); 186 | 187 | if ($definition->hasShortcut(substr($word, 1))) { 188 | return array($word); 189 | } 190 | } 191 | 192 | return false; 193 | } 194 | 195 | /** 196 | * Attempt to complete the current word as the value of an option shortcut 197 | * 198 | * @return array|false 199 | */ 200 | protected function completeForOptionShortcutValues() 201 | { 202 | $wordIndex = $this->context->getWordIndex(); 203 | 204 | if ($this->command && $wordIndex > 1) { 205 | $left = $this->context->getWordAtIndex($wordIndex - 1); 206 | 207 | // Complete short options 208 | if ($left[0] == '-' && strlen($left) == 2) { 209 | $shortcut = substr($left, 1); 210 | $def = $this->command->getNativeDefinition(); 211 | 212 | if (!$def->hasShortcut($shortcut)) { 213 | return false; 214 | } 215 | 216 | $opt = $def->getOptionForShortcut($shortcut); 217 | if ($opt->isValueRequired() || $opt->isValueOptional()) { 218 | return $this->completeOption($opt); 219 | } 220 | } 221 | } 222 | 223 | return false; 224 | } 225 | 226 | /** 227 | * Attemp to complete the current word as the value of a long-form option 228 | * 229 | * @return array|false 230 | */ 231 | protected function completeForOptionValues() 232 | { 233 | $wordIndex = $this->context->getWordIndex(); 234 | 235 | if ($this->command && $wordIndex > 1) { 236 | $left = $this->context->getWordAtIndex($wordIndex - 1); 237 | 238 | if (strpos($left, '--') === 0) { 239 | $name = substr($left, 2); 240 | $def = $this->command->getNativeDefinition(); 241 | 242 | if (!$def->hasOption($name)) { 243 | return false; 244 | } 245 | 246 | $opt = $def->getOption($name); 247 | if ($opt->isValueRequired() || $opt->isValueOptional()) { 248 | return $this->completeOption($opt); 249 | } 250 | } 251 | } 252 | 253 | return false; 254 | } 255 | 256 | /** 257 | * Attempt to complete the current word as a command name 258 | * 259 | * @return array|false 260 | */ 261 | protected function completeForCommandName() 262 | { 263 | if (!$this->command || $this->context->getWordIndex() == $this->commandWordIndex) { 264 | return $this->getCommandNames(); 265 | } 266 | 267 | return false; 268 | } 269 | 270 | /** 271 | * Attempt to complete the current word as a command argument value 272 | * 273 | * @see Symfony\Component\Console\Input\InputArgument 274 | * @return array|false 275 | */ 276 | protected function completeForCommandArguments() 277 | { 278 | if (!$this->command || strpos($this->context->getCurrentWord(), '-') === 0) { 279 | return false; 280 | } 281 | 282 | $definition = $this->command->getNativeDefinition(); 283 | $argWords = $this->mapArgumentsToWords($definition->getArguments()); 284 | $wordIndex = $this->context->getWordIndex(); 285 | 286 | if (isset($argWords[$wordIndex])) { 287 | $name = $argWords[$wordIndex]; 288 | } elseif (!empty($argWords) && $definition->getArgument(end($argWords))->isArray()) { 289 | $name = end($argWords); 290 | } else { 291 | return false; 292 | } 293 | 294 | if ($helper = $this->getCompletionHelper($name, Completion::TYPE_ARGUMENT)) { 295 | return $helper->run(); 296 | } 297 | 298 | if ($this->command instanceof CompletionAwareInterface) { 299 | return $this->command->completeArgumentValues($name, $this->context); 300 | } 301 | 302 | return false; 303 | } 304 | 305 | /** 306 | * Find a CompletionInterface that matches the current command, target name, and target type 307 | * 308 | * @param string $name 309 | * @param string $type 310 | * @return CompletionInterface|null 311 | */ 312 | protected function getCompletionHelper($name, $type) 313 | { 314 | foreach ($this->helpers as $helper) { 315 | if ($helper->getType() != $type && $helper->getType() != CompletionInterface::ALL_TYPES) { 316 | continue; 317 | } 318 | 319 | if ($helper->getCommandName() == CompletionInterface::ALL_COMMANDS || $helper->getCommandName() == $this->command->getName()) { 320 | if ($helper->getTargetName() == $name) { 321 | return $helper; 322 | } 323 | } 324 | } 325 | 326 | return null; 327 | } 328 | 329 | /** 330 | * Complete the value for the given option if a value completion is availble 331 | * 332 | * @param InputOption $option 333 | * @return array|false 334 | */ 335 | protected function completeOption(InputOption $option) 336 | { 337 | if ($helper = $this->getCompletionHelper($option->getName(), Completion::TYPE_OPTION)) { 338 | return $helper->run(); 339 | } 340 | 341 | if ($this->command instanceof CompletionAwareInterface) { 342 | return $this->command->completeOptionValues($option->getName(), $this->context); 343 | } 344 | 345 | return false; 346 | } 347 | 348 | /** 349 | * Step through the command line to determine which word positions represent which argument values 350 | * 351 | * The word indexes of argument values are found by eliminating words that are known to not be arguments (options, 352 | * option values, and command names). Any word that doesn't match for elimination is assumed to be an argument value, 353 | * 354 | * @param InputArgument[] $argumentDefinitions 355 | * @return array as [argument name => word index on command line] 356 | */ 357 | protected function mapArgumentsToWords($argumentDefinitions) 358 | { 359 | $argumentPositions = array(); 360 | $argumentNumber = 0; 361 | $previousWord = null; 362 | $argumentNames = array_keys($argumentDefinitions); 363 | 364 | // Build a list of option values to filter out 365 | $optionsWithArgs = $this->getOptionWordsWithValues(); 366 | 367 | foreach ($this->context->getWords() as $wordIndex => $word) { 368 | // Skip program name, command name, options, and option values 369 | if ($wordIndex == 0 370 | || $wordIndex === $this->commandWordIndex 371 | || ($word && '-' === $word[0]) 372 | || in_array($previousWord, $optionsWithArgs)) { 373 | $previousWord = $word; 374 | continue; 375 | } else { 376 | $previousWord = $word; 377 | } 378 | 379 | // If argument n exists, pair that argument's name with the current word 380 | if (isset($argumentNames[$argumentNumber])) { 381 | $argumentPositions[$wordIndex] = $argumentNames[$argumentNumber]; 382 | } 383 | 384 | $argumentNumber++; 385 | } 386 | 387 | return $argumentPositions; 388 | } 389 | 390 | /** 391 | * Build a list of option words/flags that will have a value after them 392 | * Options are returned in the format they appear as on the command line. 393 | * 394 | * @return string[] - eg. ['--myoption', '-m', ... ] 395 | */ 396 | protected function getOptionWordsWithValues() 397 | { 398 | $strings = array(); 399 | 400 | foreach ($this->getAllOptions() as $option) { 401 | if ($option->isValueRequired()) { 402 | $strings[] = '--' . $option->getName(); 403 | 404 | if ($option->getShortcut()) { 405 | $strings[] = '-' . $option->getShortcut(); 406 | } 407 | } 408 | } 409 | 410 | return $strings; 411 | } 412 | 413 | /** 414 | * Filter out results that don't match the current word on the command line 415 | * 416 | * @param string[] $array 417 | * @return string[] 418 | */ 419 | protected function filterResults(array $array) 420 | { 421 | $curWord = $this->context->getCurrentWord(); 422 | 423 | return array_filter($array, function($val) use ($curWord) { 424 | return fnmatch($curWord.'*', $val); 425 | }); 426 | } 427 | 428 | /** 429 | * Get the combined options of the application and entered command 430 | * 431 | * @return InputOption[] 432 | */ 433 | protected function getAllOptions() 434 | { 435 | if (!$this->command) { 436 | return $this->application->getDefinition()->getOptions(); 437 | } 438 | 439 | return array_merge( 440 | $this->command->getNativeDefinition()->getOptions(), 441 | $this->application->getDefinition()->getOptions() 442 | ); 443 | } 444 | 445 | /** 446 | * Get command names available for completion 447 | * 448 | * Filters out hidden commands where supported. 449 | * 450 | * @return string[] 451 | */ 452 | protected function getCommandNames() 453 | { 454 | $commands = array(); 455 | 456 | foreach ($this->application->all() as $name => $command) { 457 | if (!$command->isHidden()) { 458 | $commands[] = $name; 459 | } 460 | } 461 | 462 | return $commands; 463 | } 464 | 465 | /** 466 | * Find the current command name in the command-line 467 | * 468 | * Note this only cares about flag-type options. Options with values cannot 469 | * appear before a command name in Symfony Console application. 470 | * 471 | * @return Command|null 472 | */ 473 | private function detectCommand() 474 | { 475 | // Always skip the first word (program name) 476 | $skipNext = true; 477 | 478 | foreach ($this->context->getWords() as $index => $word) { 479 | 480 | // Skip word if flagged 481 | if ($skipNext) { 482 | $skipNext = false; 483 | continue; 484 | } 485 | 486 | // Skip empty words and words that look like options 487 | if (strlen($word) == 0 || $word[0] === '-') { 488 | continue; 489 | } 490 | 491 | // Return the first unambiguous match to argument-like words 492 | try { 493 | $cmd = $this->application->find($word); 494 | $this->commandWordIndex = $index; 495 | return $cmd; 496 | } catch (\InvalidArgumentException $e) { 497 | // Exception thrown, when multiple or no commands are found. 498 | } 499 | } 500 | 501 | // No command found 502 | return null; 503 | } 504 | } 505 | --------------------------------------------------------------------------------