Skip to content

Commit a9a0f78

Browse files
committed
Add support for ZSH
This commit refactors hook generation into its own class, and includes the ZSH completion hook I wrote a few months ago for stecman/composer-bash-completion-plugin.
1 parent 756620c commit a9a0f78

File tree

4 files changed

+197
-56
lines changed

4 files changed

+197
-56
lines changed

README.md

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# BASH completion for Symfony Console applications
22

3-
This package provides automatic BASH completion for Symfony Console Component based applications. With zero configuration, this package allows completion of available command names and the options they provide. Custom completion behaviour can be added for option and argument values by name.
3+
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. Custom completion behaviour can be added for option and argument values by name.
44

55
Example of zero-config use with Composer:
66

@@ -12,20 +12,28 @@ If you don't need any custom completion behaviour, all you need to do is add the
1212

1313
1. Install `stecman/symfony-console-completion` through composer
1414
2. Add an instance of `CompletionCommand` to your application's `Application::getDefaultCommands()` method:
15-
```php
16-
protected function getDefaultCommands()
17-
{
18-
//...
19-
$commands[] = new \Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand();
20-
//...
21-
}
22-
```
23-
24-
3. Run `eval $([program] _completion --generate-hook)` in a terminal, replacing `[program]` with the command you use to run your application (eg. 'composer'). By default this registers completion for the absolute path to you application; you can specify a command name to complete for instead using the `-p` option.
15+
```php
16+
protected function getDefaultCommands()
17+
{
18+
//...
19+
$commands[] = new \Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand();
20+
//...
21+
}
22+
```
23+
24+
3. Run the following in a terminal, replacing `[program]` with the command you use to run your application (eg. 'composer'):
25+
26+
```bash
27+
[program] _completion --generate-hook | source /dev/stdin
28+
```
29+
30+
By default this registers completion for the absolute path to you application. You can specify a command name to complete for instead using the `-p` option, which is useful if you're using an alias to run the program. Under BASH, an alternative command is `eval $([program] _completion --generate-hook)`, however the command above is recommended as it works for both BASH and ZSH.
2531
4. Add the command from step 3 to your bash profile if you want the completion to apply automatically for all new terminal sessions.
2632

2733
Note: If `[program]` is an alias you will need to specify the aliased name with the `-p|--program` option, as completion may not work otherwise: `_completion --generate-hook -p [myalias]`.
2834

35+
Second 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 set the shell type using the `--shell` option.
36+
2937
### How it works
3038

3139
The `--generate-hook` option of `CompletionCommand` generates a few lines of BASH that, when executed, register your application as a completion handler for your itself in the current shell session. When you request completion for your program (by pressing tab), the completion command on your application is run with no arguments: `[program] _completion`. This uses environment variables set by BASH to get the current command line contents and cursor position, then completes from your console command definitions.

src/CompletionCommand.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ protected function configure()
4141
'p',
4242
InputOption::VALUE_REQUIRED,
4343
"Program name that should trigger completion\n<comment>(defaults to the absolute application path)</comment>."
44+
)
45+
->addOption(
46+
'shell',
47+
's',
48+
InputOption::VALUE_REQUIRED,
49+
"Force the shell to generate a hook for <comment>(" . implode(', ', HookFactory::getShellTypes()) . ")</comment>\n"
4450
);
4551
}
4652

@@ -50,7 +56,17 @@ protected function execute(InputInterface $input, OutputInterface $output)
5056
$handler = $this->handler;
5157

5258
if ( $input->getOption('generate-hook') ) {
53-
$output->write( $handler->generateBashCompletionHook($input->getOption('program')), true );
59+
global $argv;
60+
$program = $argv[0];
61+
62+
$factory = new HookFactory();
63+
$hook = $factory->generateHook(
64+
$input->getOption('shell') ?: $this->getShellType(),
65+
$program,
66+
$input->getOption('program')
67+
);
68+
69+
$output->write($hook, true);
5470
} else {
5571
$handler->setContext(new EnvironmentCompletionContext());
5672
$output->write($this->runCompletion(), true);
@@ -62,4 +78,17 @@ protected function runCompletion()
6278
return $this->handler->runCompletion();
6379
}
6480

81+
/**
82+
* Determine the shell type for use with HookFactory
83+
* @return string
84+
*/
85+
protected function getShellType()
86+
{
87+
if (!getenv('SHELL')) {
88+
throw new \RuntimeException('Could not read SHELL environment variable. Please specify your shell type with the --shell option');
89+
}
90+
91+
return basename(realpath(getenv('SHELL')));
92+
}
93+
6594
}

src/CompletionHandler.php

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -332,50 +332,6 @@ protected function filterResults($array)
332332
);
333333
}
334334

335-
/**
336-
* Return the BASH script necessary to use bash completion with this addHandler
337-
* @param string $programName
338-
* @return string
339-
*/
340-
public function generateBashCompletionHook($programName = null)
341-
{
342-
global $argv;
343-
$command = $argv[0];
344-
345-
if (!$programName) {
346-
$programName = $command;
347-
$funcName = sprintf('_%s_%s_complete',
348-
basename($programName),
349-
substr(md5($command), 0, 12)
350-
);
351-
} else {
352-
$funcName = "_{$programName}complete";
353-
}
354-
355-
return <<<"END"
356-
function $funcName {
357-
export COMP_LINE COMP_POINT COMP_WORDBREAKS;
358-
local RESULT STATUS;
359-
360-
RESULT=`$command _completion`;
361-
STATUS=\$?;
362-
363-
if [ \$STATUS -ne 0 ]; then
364-
echo \$RESULT;
365-
return \$?;
366-
fi;
367-
368-
local cur;
369-
_get_comp_words_by_ref -n : cur;
370-
371-
COMPREPLY=(`compgen -W "\$RESULT" -- \$cur`);
372-
373-
__ltrim_colon_completions "\$cur";
374-
};
375-
complete -F $funcName $programName;
376-
END;
377-
}
378-
379335
protected function getAllOptions(){
380336
return array_merge(
381337
$this->command->getDefinition()->getOptions(),

src/HookFactory.php

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
4+
namespace Stecman\Component\Symfony\Console\BashCompletion;
5+
6+
7+
final class HookFactory
8+
{
9+
/**
10+
* Hook scripts
11+
*
12+
* These are shell-specific scripts that pass required information from existing
13+
* completion systems in a common form the completion component of this module.
14+
*
15+
* The following placeholders are replaced with their value at runtime:
16+
*
17+
* %%function_name%% - name of the generated shell function run for completion
18+
* %%program_name%% - command name completion will be enabled for
19+
* %%program_path%% - path to program the completion is for/generated by
20+
* %%completion_command%% - command to be run to compute completions
21+
*
22+
* NOTE: Comments are stripped out by HookFactory::stripComments as eval reads
23+
* input as a single line, causing it to break if comments are included.
24+
* While comments work using `... | source /dev/stdin`, existing installations
25+
* are likely using eval as it's been part of the instructions for a while.
26+
*
27+
* @var array
28+
*/
29+
protected static $hooks = array(
30+
// BASH Hook
31+
'bash' => <<<'END'
32+
# BASH completion for %%program_path%%
33+
function %%function_name%% {
34+
export COMP_LINE COMP_POINT COMP_WORDBREAKS;
35+
local RESULT STATUS;
36+
37+
RESULT=`%%completion_command%%`;
38+
STATUS=$?;
39+
40+
# Bail out if PHP didn't exit cleanly
41+
if [ $STATUS -ne 0 ]; then
42+
echo $RESULT;
43+
return $?;
44+
fi;
45+
46+
local cur;
47+
_get_comp_words_by_ref -n : cur;
48+
49+
COMPREPLY=(`compgen -W "$RESULT" -- \$cur`);
50+
51+
__ltrim_colon_completions "$cur";
52+
};
53+
54+
complete -F %%function_name%% %%program_name%%;
55+
END
56+
57+
// ZSH Hook
58+
, 'zsh' => <<<'END'
59+
# ZSH completion for %%program_path%%
60+
function %%function_name%% {
61+
# Emulate BASH's command line contents variable
62+
local -x COMP_LINE="$words"
63+
64+
# Emulate BASH's cursor position variable, setting it to the end of the current word.
65+
local -x COMP_POINT
66+
(( COMP_POINT = ${#${(j. .)words[1,CURRENT]}} ))
67+
68+
local RESULT STATUS
69+
local -x COMPOSER_CWD=`pwd`
70+
RESULT=("${(@f)$( %%completion_command%% )}")
71+
STATUS=$?;
72+
73+
# Bail out if PHP didn't exit cleanly
74+
if [ $STATUS -ne 0 ]; then
75+
echo $RESULT;
76+
return $?;
77+
fi;
78+
79+
compadd -- $RESULT
80+
};
81+
82+
compdef %%function_name%% %%program_name%%;
83+
END
84+
);
85+
86+
public static function getShellTypes()
87+
{
88+
return array_keys(self::$hooks);
89+
}
90+
91+
/**
92+
* Return a completion hook for the specified shell type
93+
*
94+
* @param string $type - a key from self::$hooks
95+
* @param string $programPath
96+
* @param string $programName
97+
* @return string
98+
*/
99+
public function generateHook($type, $programPath, $programName = null)
100+
{
101+
if (!isset(self::$hooks[$type])) {
102+
throw new \RuntimeException(sprintf(
103+
"Cannot generate hook for unknown shell type '%s'. Available hooks are: %s",
104+
$type,
105+
implode(', ', self::getShellTypes())
106+
));
107+
}
108+
109+
// Use the program path if an alias/name is not given
110+
$programName = $programName ?: $programPath;
111+
112+
return str_replace([
113+
'%%function_name%%',
114+
'%%program_name%%',
115+
'%%program_path%%',
116+
'%%completion_command%%',
117+
], [
118+
$this->generateFunctionName($programPath, $programName),
119+
$programName,
120+
$programPath,
121+
"$programPath _completion"
122+
],
123+
$this->stripComments(self::$hooks[$type])
124+
);
125+
}
126+
127+
/**
128+
* Generate a function name that is unlikely to conflict with other
129+
* generated function names in the same shell
130+
*/
131+
protected function generateFunctionName($programPath, $programName)
132+
{
133+
return sprintf('_%s_%s_complete',
134+
basename($programName),
135+
substr(md5($programPath), 0, 16)
136+
);
137+
}
138+
139+
/**
140+
* BASH's eval doesn't work with comments, so these have to be stripped out
141+
* @param string $script
142+
* @return string
143+
*/
144+
protected function stripComments($script)
145+
{
146+
return preg_replace('/(^\s*\#.*$)/m', '', $script);
147+
}
148+
}

0 commit comments

Comments
 (0)