diff --git a/docs/1-essentials/04-console-commands.md b/docs/1-essentials/04-console-commands.md index 7ecf4c7b5e..c6623f309f 100644 --- a/docs/1-essentials/04-console-commands.md +++ b/docs/1-essentials/04-console-commands.md @@ -238,6 +238,41 @@ Tempest console comes with a range of interactive components that can be used to Interactive components are only supported on Mac and Linux. On Windows, Tempest will fall back to non-interactive versions of these components. ::: +## Shell completion + +Tempest provides shell completion for Zsh and Bash. This allows you to press `Tab` to autocomplete command names and options. + +### Installing completions + +Run the install command and follow the prompts: + +```console +./tempest completion:install +``` + +The installer will detect your current shell, copy the completion script to the appropriate location, and provide instructions for enabling it. + +For Zsh, you'll need to ensure the completions directory is in your `fpath` and reload completions: + +```zsh +# Add to ~/.zshrc +fpath=(~/.zsh/completions $fpath) +autoload -Uz compinit && compinit +``` + +For Bash, source the completion file in your `~/.bashrc`: + +```bash +source ~/.bash_completion.d/tempest.bash +``` + +### Additional commands + +You may also use these related commands: + +- `completion:show` — Output the completion script to stdout (useful for custom installation) +- `completion:uninstall` — Remove the installed completion script + ## Middleware Console middleware can be applied globally or on a per-command basis. Global console middleware will be discovered and applied automatically, by priority order. diff --git a/packages/console/src/Actions/ResolveShell.php b/packages/console/src/Actions/ResolveShell.php new file mode 100644 index 0000000000..89d45e4f3d --- /dev/null +++ b/packages/console/src/Actions/ResolveShell.php @@ -0,0 +1,31 @@ +console->supportsPrompting()) { + /** @var Shell */ + return $this->console->ask( + question: $question, + options: Shell::class, + default: $detected, + ); + } + + return $detected; + } +} diff --git a/packages/console/src/Commands/CompletionInstallCommand.php b/packages/console/src/Commands/CompletionInstallCommand.php new file mode 100644 index 0000000000..73e607c0a5 --- /dev/null +++ b/packages/console/src/Commands/CompletionInstallCommand.php @@ -0,0 +1,98 @@ +resolveShell)('Which shell do you want to install completions for?'); + + if ($shell === null) { + $this->console->error('Could not detect shell. Please specify one using the --shell option. Possible values are: zsh, bash.'); + + return ExitCode::ERROR; + } + + $sourcePath = $this->getSourcePath($shell); + $targetDir = $shell->getCompletionsDirectory(); + $targetPath = $shell->getInstalledCompletionPath(); + + if (! Filesystem\is_file($sourcePath)) { + $this->console->error("Completion script not found: {$sourcePath}"); + + return ExitCode::ERROR; + } + + if (! $force) { + $this->console->info("Installing {$shell->value} completions"); + $this->console->keyValue('Source', $sourcePath); + $this->console->keyValue('Target', $targetPath); + $this->console->writeln(); + + if (! $this->console->confirm('Proceed with installation?', default: true)) { + $this->console->warning('Installation cancelled.'); + + return ExitCode::CANCELLED; + } + } + + Filesystem\ensure_directory_exists($targetDir); + + if (Filesystem\is_file($targetPath)) { + if (! $force && ! $this->console->confirm('Completion file already exists. Overwrite?', default: false)) { + $this->console->warning('Installation cancelled.'); + + return ExitCode::CANCELLED; + } + } + + Filesystem\copy_file($sourcePath, $targetPath, overwrite: true); + $this->console->success("Installed completion script to: {$targetPath}"); + + $this->console->writeln(); + $this->console->info('Next steps:'); + $this->console->instructions($shell->getPostInstallInstructions()); + + return ExitCode::SUCCESS; + } + + private function getSourcePath(Shell $shell): string + { + return Path::canonicalize( + path(__DIR__, '..', $shell->getSourceFilename())->toString(), + ); + } +} diff --git a/packages/console/src/Commands/CompletionShowCommand.php b/packages/console/src/Commands/CompletionShowCommand.php new file mode 100644 index 0000000000..1afeb32213 --- /dev/null +++ b/packages/console/src/Commands/CompletionShowCommand.php @@ -0,0 +1,63 @@ +resolveShell)('Which shell completion script do you want to see?'); + + if ($shell === null) { + $this->console->error('Could not detect shell. Please specify one using the --shell option. Possible values are: zsh, bash.'); + + return ExitCode::ERROR; + } + + $sourcePath = $this->getSourcePath($shell); + + if (! Filesystem\is_file($sourcePath)) { + $this->console->error("Completion script not found: {$sourcePath}"); + + return ExitCode::ERROR; + } + + $this->console->writeRaw(Filesystem\read_file($sourcePath)); + + return ExitCode::SUCCESS; + } + + private function getSourcePath(Shell $shell): string + { + return Path::canonicalize( + path(__DIR__, '..', $shell->getSourceFilename())->toString(), + ); + } +} diff --git a/packages/console/src/Commands/CompletionUninstallCommand.php b/packages/console/src/Commands/CompletionUninstallCommand.php new file mode 100644 index 0000000000..2a5cbdca5d --- /dev/null +++ b/packages/console/src/Commands/CompletionUninstallCommand.php @@ -0,0 +1,76 @@ +resolveShell)('Which shell completions do you want to uninstall?'); + + if ($shell === null) { + $this->console->error('Could not detect shell. Please specify one using the --shell option. Possible values are: zsh, bash.'); + + return ExitCode::ERROR; + } + + $targetPath = $shell->getInstalledCompletionPath(); + + if (! Filesystem\is_file($targetPath)) { + $this->console->warning("Completion file not found: {$targetPath}"); + $this->console->info('Nothing to uninstall.'); + + return ExitCode::SUCCESS; + } + + if (! $force) { + $this->console->info("Uninstalling {$shell->value} completions"); + $this->console->keyValue('File', $targetPath); + $this->console->writeln(); + + if (! $this->console->confirm('Proceed with uninstallation?', default: true)) { + $this->console->warning('Uninstallation cancelled.'); + + return ExitCode::CANCELLED; + } + } + + Filesystem\delete_file($targetPath); + $this->console->success("Removed completion script: {$targetPath}"); + + $this->console->writeln(); + $this->console->info('Remember to remove any related lines from your shell configuration:'); + $this->console->keyValue('Config file', $shell->getRcFile()); + + return ExitCode::SUCCESS; + } +} diff --git a/packages/console/src/Enums/Shell.php b/packages/console/src/Enums/Shell.php new file mode 100644 index 0000000000..ddf4ae28df --- /dev/null +++ b/packages/console/src/Enums/Shell.php @@ -0,0 +1,94 @@ + self::ZSH, + str_contains($shell, 'bash') => self::BASH, + default => null, + }; + } + + public function getCompletionsDirectory(): string + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + return match ($this) { + self::ZSH => $home . '/.zsh/completions', + self::BASH => $home . '/.bash_completion.d', + }; + } + + public function getCompletionFilename(): string + { + return match ($this) { + self::ZSH => '_tempest', + self::BASH => 'tempest.bash', + }; + } + + public function getInstalledCompletionPath(): string + { + return $this->getCompletionsDirectory() . '/' . $this->getCompletionFilename(); + } + + public function getSourceFilename(): string + { + return match ($this) { + self::ZSH => 'completion.zsh', + self::BASH => 'completion.bash', + }; + } + + public function getRcFile(): string + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + return match ($this) { + self::ZSH => $home . '/.zshrc', + self::BASH => $home . '/.bashrc', + }; + } + + /** + * @return string[] + */ + public function getPostInstallInstructions(): array + { + return match ($this) { + self::ZSH => [ + 'Add the completions directory to your fpath in ~/.zshrc:', + '', + ' fpath=(~/.zsh/completions $fpath)', + '', + 'Then reload completions:', + '', + ' autoload -Uz compinit && compinit', + '', + 'Or restart your terminal.', + ], + self::BASH => [ + 'Source the completion file in your ~/.bashrc:', + '', + ' source ~/.bash_completion.d/tempest.bash', + '', + 'Or restart your terminal.', + ], + }; + } +} diff --git a/packages/console/src/complete.zsh b/packages/console/src/complete.zsh deleted file mode 100644 index 92ff620f5e..0000000000 --- a/packages/console/src/complete.zsh +++ /dev/null @@ -1,84 +0,0 @@ -# Forked from https://github.com/symfony/console/tree/7.0/Resources - -#compdef tempest - -# This file is part of the Symfony package. -# -# (c) Fabien Potencier -# -# For the full copyright and license information, please view -# https://symfony.com/doc/current/contributing/code/license.html - -# -# zsh completions for tempest -# -# References: -# - https://github.com/spf13/cobra/blob/master/zsh_completions.go -# - https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/Console/Resources/completion.bash -# -_sf_tempest() { - local lastParam flagPrefix requestComp out comp - local -a completions - - # The user could have moved the cursor backwards on the command-line. - # We need to trigger completion from the $CURRENT location, so we need - # to truncate the command-line ($words) up to the $CURRENT location. - # (We cannot use $CURSOR as its value does not work when a command is an alias.) - words=("${=words[1,CURRENT]}") lastParam=${words[-1]} - - # For zsh, when completing a flag with an = (e.g., tempest -n=) - # completions must be prefixed with the flag - setopt local_options BASH_REMATCH - if [[ "${lastParam}" =~ '-.*=' ]]; then - # We are dealing with a flag with an = - flagPrefix="-P ${BASH_REMATCH}" - fi - - # Prepare the command to obtain completions - requestComp="${words[0]} ${words[1]} _complete --no-interaction --shell=zsh --current=$((CURRENT-1))" i="" - for w in ${words[@]}; do - w=$(printf -- '%b' "$w") - # remove quotes from typed values - quote="${w:0:1}" - if [ "$quote" = \' ]; then - w="${w%\'}" - w="${w#\'}" - elif [ "$quote" = \" ]; then - w="${w%\"}" - w="${w#\"}" - fi - # empty values are ignored - if [ ! -z "$w" ]; then - i="${i}--input=\"${w}\" " - fi - done - - # Ensure at least 1 input - if [ "${i}" = "" ]; then - requestComp="${requestComp} --input=\" \"" - else - requestComp="${requestComp} ${i}" - fi - - # Use eval to handle any environment variables and such - out=$(eval ${requestComp} 2>/dev/null) - - while IFS='\n' read -r comp; do - if [ -n "$comp" ]; then - # If requested, completions are returned with a description. - # The description is preceded by a TAB character. - # For zsh's _describe, we need to use a : instead of a TAB. - # We first need to escape any : as part of the completion itself. - comp=${comp//:/\\:} - local tab=$(printf '\t') - comp=${comp//$tab/:} - completions+=${comp} - fi - done < <(printf "%s\n" "${out[@]}") - - # Let inbuilt _describe handle completions - eval _describe "completions" completions $flagPrefix - return $? -} - -compdef _sf_tempest tempest diff --git a/packages/console/src/completion.bash b/packages/console/src/completion.bash new file mode 100644 index 0000000000..b700571e41 --- /dev/null +++ b/packages/console/src/completion.bash @@ -0,0 +1,45 @@ +# Tempest Framework Bash Completion +# Supports: ./tempest, tempest, php tempest, php vendor/bin/tempest, etc. + +_tempest() { + local cur tempest_cmd shift_count input_args output IFS + + # Initialize current word (use bash-completion if available) + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion || return + else + cur="${COMP_WORDS[COMP_CWORD]}" + fi + + # Detect invocation pattern and build command + if [[ "${COMP_WORDS[0]}" == "php" ]]; then + tempest_cmd="${COMP_WORDS[0]} ${COMP_WORDS[1]}" + shift_count=2 + else + tempest_cmd="${COMP_WORDS[0]}" + shift_count=1 + fi + + # Build _complete arguments, skipping "php" prefix if present + input_args="--current=$((COMP_CWORD - shift_count + 1))" + for ((i = shift_count - 1; i < ${#COMP_WORDS[@]}; i++)); do + input_args+=" --input=\"${COMP_WORDS[i]}\"" + done + + # Execute completion command + output=$(eval "$tempest_cmd _complete $input_args" 2>/dev/null) || return 0 + [[ -z "$output" ]] && return 0 + + # Parse and filter completions + IFS=$'\n' + COMPREPLY=($(compgen -W "$output" -- "$cur")) + + # Suppress trailing space for flags expecting values (bash 4.0+) + if [[ ${#COMPREPLY[@]} -eq 1 && "${COMPREPLY[0]}" == *= ]] && type compopt &>/dev/null; then + compopt -o nospace + fi +} + +complete -F _tempest ./tempest +complete -F _tempest tempest +complete -F _tempest php diff --git a/packages/console/src/completion.zsh b/packages/console/src/completion.zsh new file mode 100644 index 0000000000..1017276ef0 --- /dev/null +++ b/packages/console/src/completion.zsh @@ -0,0 +1,54 @@ +#compdef -p '*/tempest' -p 'tempest' php + +# Tempest Framework Zsh Completion + +_tempest() { + local current="${words[CURRENT]}" tempest_cmd shift_count output + local -a args with_suffix without_suffix + + # Detect invocation: "php tempest ..." vs "./tempest ..." + if [[ "${words[1]}" == "php" ]]; then + tempest_cmd="${words[1]} ${words[2]}" + shift_count=2 + else + tempest_cmd="${words[1]}" + shift_count=1 + fi + + # Build completion request arguments + # Skip "php" from inputs but keep the tempest binary and args + local skip=$((shift_count - 1)) + args=("--current=$((CURRENT - shift_count))") + for word in "${words[@]:$skip}"; do + args+=("--input=$word") + done + + # Fetch completions from tempest + output=$(eval "$tempest_cmd _complete ${args[*]}" 2>/dev/null) || return 0 + [[ -z "$output" ]] && return 0 + + # Parse completions, separating by suffix behavior + for line in "${(@f)output}"; do + [[ -z "$line" ]] && continue + if [[ "$line" == *= ]]; then + without_suffix+=("$line") + else + with_suffix+=("${line//:/\\:}") + fi + done + + # Add completions: no trailing space for "=" options, use _describe for commands + (( ${#without_suffix} )) && compadd -Q -S '' -- "${without_suffix[@]}" + + if (( ${#with_suffix} )); then + if [[ "$current" == -* || "${with_suffix[1]}" == -* ]]; then + compadd -Q -- "${with_suffix[@]}" + else + _describe -t commands 'tempest commands' with_suffix + fi + fi +} + +compdef _tempest -p '*/tempest' +compdef _tempest -p 'tempest' +compdef _tempest php diff --git a/packages/console/tests/Enums/ShellTest.php b/packages/console/tests/Enums/ShellTest.php new file mode 100644 index 0000000000..dcf569e969 --- /dev/null +++ b/packages/console/tests/Enums/ShellTest.php @@ -0,0 +1,108 @@ +assertSame($expected, $result); + } finally { + if ($originalShell === false) { + putenv('SHELL'); + } else { + putenv("SHELL={$originalShell}"); + } + } + } + + public static function detectDataProvider(): array + { + return [ + 'zsh' => ['/bin/zsh', Shell::ZSH], + 'bash' => ['/bin/bash', Shell::BASH], + 'usr local zsh' => ['/usr/local/bin/zsh', Shell::ZSH], + 'usr local bash' => ['/usr/local/bin/bash', Shell::BASH], + 'fish' => ['/bin/fish', null], + 'empty' => ['', null], + 'not set' => [false, null], + ]; + } + + #[Test] + public function getCompletionsDirectory(): void + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + $this->assertSame($home . '/.zsh/completions', Shell::ZSH->getCompletionsDirectory()); + $this->assertSame($home . '/.bash_completion.d', Shell::BASH->getCompletionsDirectory()); + } + + #[Test] + public function getCompletionFilename(): void + { + $this->assertSame('_tempest', Shell::ZSH->getCompletionFilename()); + $this->assertSame('tempest.bash', Shell::BASH->getCompletionFilename()); + } + + #[Test] + public function getInstalledCompletionPath(): void + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + $this->assertSame($home . '/.zsh/completions/_tempest', Shell::ZSH->getInstalledCompletionPath()); + $this->assertSame($home . '/.bash_completion.d/tempest.bash', Shell::BASH->getInstalledCompletionPath()); + } + + #[Test] + public function getSourceFilename(): void + { + $this->assertSame('completion.zsh', Shell::ZSH->getSourceFilename()); + $this->assertSame('completion.bash', Shell::BASH->getSourceFilename()); + } + + #[Test] + public function getRcFile(): void + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + $this->assertSame($home . '/.zshrc', Shell::ZSH->getRcFile()); + $this->assertSame($home . '/.bashrc', Shell::BASH->getRcFile()); + } + + #[Test] + public function getPostInstallInstructions(): void + { + $zshInstructions = Shell::ZSH->getPostInstallInstructions(); + $this->assertIsArray($zshInstructions); + $this->assertNotEmpty($zshInstructions); + $this->assertStringContainsString('fpath', $zshInstructions[0]); + + $bashInstructions = Shell::BASH->getPostInstallInstructions(); + $this->assertIsArray($bashInstructions); + $this->assertNotEmpty($bashInstructions); + $this->assertStringContainsStringIgnoringCase('source', $bashInstructions[0]); + } +} diff --git a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php new file mode 100644 index 0000000000..ac5d80ccf0 --- /dev/null +++ b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php @@ -0,0 +1,123 @@ +installedFile !== null && Filesystem\is_file($this->installedFile)) { + Filesystem\delete_file($this->installedFile); + $this->installedFile = null; + } + + parent::tearDown(); + } + + #[Test] + public function install_with_explicit_shell_flag(): void + { + $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + + $this->console + ->call('completion:install --shell=zsh --force') + ->assertSee('Installed completion script to:') + ->assertSee('_tempest') + ->assertSuccess(); + } + + #[Test] + public function install_with_invalid_shell(): void + { + $this->console + ->withoutPrompting() + ->call('completion:install --shell=fish') + ->assertSee('Invalid argument `fish` for `shell` argument') + ->assertError(); + } + + #[Test] + public function install_shows_post_install_instructions_for_zsh(): void + { + $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + + $this->console + ->call('completion:install --shell=zsh --force') + ->assertSee('fpath=') + ->assertSee('compinit') + ->assertSuccess(); + } + + #[Test] + public function install_shows_post_install_instructions_for_bash(): void + { + $this->installedFile = Shell::BASH->getInstalledCompletionPath(); + + $this->console + ->call('completion:install --shell=bash --force') + ->assertSee('source') + ->assertSee('tempest.bash') + ->assertSuccess(); + } + + #[Test] + public function install_cancelled_when_user_denies_confirmation(): void + { + $this->console + ->call('completion:install --shell=zsh') + ->assertSee('Installing zsh completions') + ->deny() + ->assertSee('Installation cancelled') + ->assertCancelled(); + } + + #[Test] + public function install_creates_directory_if_not_exists(): void + { + $targetDir = Shell::ZSH->getCompletionsDirectory(); + $dirExisted = Filesystem\is_directory($targetDir); + + $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + + $result = $this->console + ->call('completion:install --shell=zsh --force'); + + if (! $dirExisted) { + $result->assertSee('Created directory:'); + } + + $result->assertSuccess(); + } + + #[Test] + public function install_asks_for_overwrite_when_file_exists(): void + { + $targetPath = Shell::ZSH->getInstalledCompletionPath(); + $targetDir = Shell::ZSH->getCompletionsDirectory(); + + Filesystem\create_directory($targetDir); + Filesystem\write_file($targetPath, '# existing content'); + + $this->installedFile = $targetPath; + + $this->console + ->call('completion:install --shell=zsh') + ->confirm() + ->assertSee('Completion file already exists') + ->deny() + ->assertSee('Installation cancelled') + ->assertCancelled(); + } +} diff --git a/tests/Integration/Console/Commands/CompletionShowCommandTest.php b/tests/Integration/Console/Commands/CompletionShowCommandTest.php new file mode 100644 index 0000000000..d4c1b32510 --- /dev/null +++ b/tests/Integration/Console/Commands/CompletionShowCommandTest.php @@ -0,0 +1,42 @@ +console + ->call('completion:show --shell=zsh') + ->assertSee('_tempest') + ->assertSuccess(); + } + + #[Test] + public function show_bash_completion_script(): void + { + $this->console + ->call('completion:show --shell=bash') + ->assertSee('_tempest') + ->assertSuccess(); + } + + #[Test] + public function show_with_invalid_shell(): void + { + $this->console + ->withoutPrompting() + ->call('completion:show --shell=fish') + ->assertSee('Invalid argument `fish` for `shell` argument') + ->assertError(); + } +} diff --git a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php new file mode 100644 index 0000000000..4f042f4583 --- /dev/null +++ b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php @@ -0,0 +1,98 @@ +getInstalledCompletionPath(); + $targetDir = Shell::ZSH->getCompletionsDirectory(); + + Filesystem\create_directory($targetDir); + Filesystem\write_file($targetPath, '# completion script'); + + $this->console + ->call('completion:uninstall --shell=zsh --force') + ->assertSee('Removed completion script:') + ->assertSee('_tempest') + ->assertSuccess(); + + $this->assertFalse(Filesystem\is_file($targetPath)); + } + + #[Test] + public function uninstall_with_invalid_shell(): void + { + $this->console + ->withoutPrompting() + ->call('completion:uninstall --shell=fish') + ->assertSee('Invalid argument `fish` for `shell` argument') + ->assertError(); + } + + #[Test] + public function uninstall_when_file_not_exists(): void + { + $targetPath = Shell::ZSH->getInstalledCompletionPath(); + + if (Filesystem\is_file($targetPath)) { + Filesystem\delete_file($targetPath); + } + + $this->console + ->withoutPrompting() + ->call('completion:uninstall --shell=zsh --force') + ->assertSee('Completion file not found') + ->assertSee('Nothing to uninstall') + ->assertSuccess(); + } + + #[Test] + public function uninstall_shows_config_file_reminder(): void + { + $targetPath = Shell::BASH->getInstalledCompletionPath(); + $targetDir = Shell::BASH->getCompletionsDirectory(); + + Filesystem\create_directory($targetDir); + Filesystem\write_file($targetPath, '# completion script'); + + $this->console + ->call('completion:uninstall --shell=bash --force') + ->assertSee('Remember to remove any related lines') + ->assertSee('.bashrc') + ->assertSuccess(); + } + + #[Test] + public function uninstall_cancelled_when_user_denies_confirmation(): void + { + $targetPath = Shell::ZSH->getInstalledCompletionPath(); + $targetDir = Shell::ZSH->getCompletionsDirectory(); + + Filesystem\create_directory($targetDir); + Filesystem\write_file($targetPath, '# completion script'); + + $this->console + ->call('completion:uninstall --shell=zsh') + ->assertSee('Uninstalling zsh completions') + ->deny() + ->assertSee('Uninstallation cancelled') + ->assertCancelled(); + + $this->assertTrue(Filesystem\is_file($targetPath)); + + Filesystem\delete_file($targetPath); + } +}