├── .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 | [](https://scrutinizer-ci.com/g/stecman/symfony-console-completion/?branch=master)
4 |
5 | [](https://packagist.org/packages/stecman/symfony-console-completion)
6 | [](https://packagist.org/packages/stecman/symfony-console-completion)
7 | [](https://packagist.org/packages/stecman/symfony-console-completion)
8 | [](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 | 
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 |
--------------------------------------------------------------------------------