diff --git a/packages/console/src/Commands/CompletionInstallCommand.php b/packages/console/src/Commands/CompletionInstallCommand.php
new file mode 100644
index 000000000..7ef10de31
--- /dev/null
+++ b/packages/console/src/Commands/CompletionInstallCommand.php
@@ -0,0 +1,235 @@
+resolveShell($shell);
+
+ if ($targetShell === null) {
+ $this->error('Could not detect shell. Please specify one with --shell=bash or --shell=zsh');
+
+ return;
+ }
+
+ if (! $this->confirm("Install completion for {$targetShell->value}?", default: true)) {
+ $this->info('Installation cancelled.');
+
+ return;
+ }
+
+ $method = $this->ask(
+ question: 'How would you like to install completion?',
+ options: [
+ 'source' => 'Source (add source line to shell config)',
+ 'copy' => 'Copy (copy script to completions directory)',
+ 'manual' => 'Manual (show instructions only)',
+ ],
+ );
+
+ $success = match ($method) {
+ 'source' => $this->installWithSource($targetShell),
+ 'copy' => $this->installWithCopy($targetShell),
+ 'manual' => $this->showManualInstructions($targetShell),
+ default => false,
+ };
+
+ if ($success) {
+ $this->writeln();
+ $this->success('Shell completion installed successfully!');
+ }
+
+ if ($success || $method === 'manual') {
+ $this->showReloadInstructions($targetShell);
+ }
+ }
+
+ private function installWithSource(Shell $shell): bool
+ {
+ $rcFile = $shell->rcFile();
+ $completionScriptPath = $this->getCompletionScriptPath($shell);
+ $currentContent = $this->getRcContent($rcFile, $shell);
+
+ if ($currentContent === null) {
+ return false;
+ }
+
+ $sourceLine = self::COMPLETION_MARKER . PHP_EOL;
+ $sourceLine .= "source \"{$completionScriptPath}\"" . PHP_EOL;
+
+ $newContent = rtrim($currentContent) . PHP_EOL . PHP_EOL . $sourceLine;
+
+ write_file($rcFile, $newContent);
+ $this->info("Added source line to {$rcFile}");
+
+ return true;
+ }
+
+ private function getRcContent(string $rcFile, Shell $shell): ?string
+ {
+ if (! is_file($rcFile)) {
+ return '';
+ }
+
+ $content = read_file($rcFile);
+
+ if (! str($content)->contains(self::COMPLETION_MARKER)) {
+ return $content;
+ }
+
+ $this->warning("Completion already installed in {$rcFile}");
+
+ if (! $this->confirm('Do you want to reinstall?', default: false)) {
+ return null;
+ }
+
+ return $content |> $this->removeCompletionLines(...);
+ }
+
+ private function installWithCopy(Shell $shell): bool
+ {
+ $completionsDir = $shell->completionsDirectory();
+ $destinationPath = $completionsDir . '/' . $shell->completionScriptName();
+ $sourcePath = $this->getCompletionScriptPath($shell);
+
+ if (! $this->ensureDirectoryExists($completionsDir)) {
+ return false;
+ }
+
+ if (! $this->canWriteToDestination($destinationPath)) {
+ return false;
+ }
+
+ write_file($destinationPath, read_file($sourcePath));
+ $this->info("Copied completion script to {$destinationPath}");
+
+ if ($shell === Shell::ZSH) {
+ $this->ensureZshFpath($shell, $completionsDir);
+ }
+
+ return true;
+ }
+
+ private function ensureDirectoryExists(string $dir): bool
+ {
+ if (is_directory($dir)) {
+ return true;
+ }
+
+ if (! $this->confirm("Create directory {$dir}?", default: true)) {
+ return false;
+ }
+
+ create_directory($dir);
+
+ return true;
+ }
+
+ private function canWriteToDestination(string $path): bool
+ {
+ if (! is_file($path)) {
+ return true;
+ }
+
+ $this->warning("File already exists: {$path}");
+
+ return $this->confirm('Do you want to overwrite it?', default: false);
+ }
+
+ private function showManualInstructions(Shell $shell): bool
+ {
+ $completionScriptPath = $this->getCompletionScriptPath($shell);
+ $rcFile = $shell->rcFile();
+ $completionsDir = $shell->completionsDirectory();
+
+ $this->writeln();
+ $this->header('Manual Installation Instructions');
+
+ $this->writeln();
+ $this->writeln('Option 1: Source the completion script');
+ $this->writeln("Add this line to your {$rcFile}:");
+ $this->writeln();
+ $this->writeln(" source \"{$completionScriptPath}\"");
+
+ $this->writeln();
+ $this->writeln('Option 2: Copy to completions directory');
+ $this->writeln('1. Create the completions directory (if needed):');
+ $this->writeln(" mkdir -p {$completionsDir}");
+ $this->writeln();
+ $this->writeln('2. Copy the completion script:');
+ $this->writeln(" cp \"{$completionScriptPath}\" \"{$completionsDir}/{$shell->completionScriptName()}\"");
+ $this->writeln();
+
+ if ($shell === Shell::ZSH) {
+ $this->writeln("3. Add to fpath in {$rcFile} (before compinit):");
+ $this->writeln(" fpath=({$completionsDir} \$fpath)");
+ }
+
+ return false;
+ }
+
+ private function ensureZshFpath(Shell $shell, string $completionsDir): void
+ {
+ $rcFile = $shell->rcFile();
+ $content = is_file($rcFile) ? read_file($rcFile) : '';
+ $stringable = str($content);
+
+ if ($stringable->contains($completionsDir)) {
+ $this->info("fpath already configured in {$rcFile}");
+
+ return;
+ }
+
+ if ($stringable->contains(self::COMPLETION_MARKER)) {
+ $this->warning('Completion marker found but fpath not configured. Updating...');
+ $content = $content |> $this->removeCompletionLines(...);
+ }
+
+ if (! $this->confirm("Add fpath configuration to {$rcFile}?", default: true)) {
+ $this->writeln();
+ $this->warning('You need to manually add this to your shell config:');
+ $this->writeln(" fpath=({$completionsDir} \$fpath)");
+ $this->writeln(' autoload -Uz compinit && compinit');
+
+ return;
+ }
+
+ $fpathLine = self::COMPLETION_MARKER . PHP_EOL;
+ $fpathLine .= "fpath=({$completionsDir} \$fpath)" . PHP_EOL;
+ $fpathLine .= 'autoload -Uz compinit && compinit' . PHP_EOL;
+
+ write_file($rcFile, rtrim($content) . PHP_EOL . PHP_EOL . $fpathLine);
+ $this->info("Added fpath configuration to {$rcFile}");
+ }
+
+ private function showReloadInstructions(Shell $shell): void
+ {
+ $this->writeln();
+ $this->info('To activate completion, either:');
+ $this->writeln(' 1. Open a new terminal window, or');
+ $this->writeln(" 2. Run: source {$shell->rcFile()}");
+ }
+}
diff --git a/packages/console/src/Commands/CompletionUninstallCommand.php b/packages/console/src/Commands/CompletionUninstallCommand.php
new file mode 100644
index 000000000..72e3c4834
--- /dev/null
+++ b/packages/console/src/Commands/CompletionUninstallCommand.php
@@ -0,0 +1,127 @@
+resolveShell($shell);
+
+ if ($targetShell === null) {
+ $this->error('Could not detect shell. Please specify one with --shell=bash or --shell=zsh');
+
+ return;
+ }
+
+ $installations = $this->detectInstallations($targetShell);
+
+ if ($installations === []) {
+ $this->info("No completion installation found for {$targetShell->value}");
+
+ return;
+ }
+
+ $this->writeln('Found completion installations:');
+
+ foreach ($installations as $type => $path) {
+ $this->keyValue($type, $path);
+ }
+
+ $this->writeln();
+
+ if (! $this->confirm('Remove all found installations?', default: true)) {
+ $this->info('Uninstallation cancelled.');
+
+ return;
+ }
+
+ $rcRemoved = ! isset($installations['rc']) || $this->removeRcInstallation($targetShell);
+ $copyRemoved = ! isset($installations['copy']) || $this->removeCopiedFile($installations['copy']);
+
+ if ($rcRemoved && $copyRemoved) {
+ $this->writeln();
+ $this->success('Shell completion removed successfully!');
+ $this->writeln('Reload your shell to apply changes.');
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function detectInstallations(Shell $shell): array
+ {
+ $installations = [];
+
+ $rcFile = $shell->rcFile();
+
+ if (is_file($rcFile) && str(read_file($rcFile))->contains(self::COMPLETION_MARKER)) {
+ $installations['rc'] = $rcFile;
+ }
+
+ $copiedPath = $shell->completionsDirectory() . '/' . $shell->completionScriptName();
+
+ if (is_file($copiedPath) && $this->isTempestCompletionScript($copiedPath)) {
+ $installations['copy'] = $copiedPath;
+ }
+
+ return $installations;
+ }
+
+ private function isTempestCompletionScript(string $path): bool
+ {
+ $content = str(read_file($path));
+
+ return $content->contains('tempest') || $content->contains('_sf_tempest');
+ }
+
+ private function removeRcInstallation(Shell $shell): bool
+ {
+ $rcFile = $shell->rcFile();
+
+ if (! is_file($rcFile)) {
+ return true;
+ }
+
+ $newContent = read_file($rcFile)
+ |> $this->removeCompletionLines(...)
+ |> (static fn (string $c): string => preg_replace("/\n{3,}/", "\n\n", $c) ?? $c);
+
+ write_file($rcFile, $newContent);
+ $this->info("Removed completion config from {$rcFile}");
+
+ return true;
+ }
+
+ private function removeCopiedFile(string $path): bool
+ {
+ if (! is_file($path)) {
+ return true;
+ }
+
+ delete_file($path);
+ $this->info("Removed completion script: {$path}");
+
+ return true;
+ }
+}
diff --git a/packages/console/src/Enums/Shell.php b/packages/console/src/Enums/Shell.php
new file mode 100644
index 000000000..8cbf6e22d
--- /dev/null
+++ b/packages/console/src/Enums/Shell.php
@@ -0,0 +1,70 @@
+getHomeDirectory();
+
+ return match ($this) {
+ self::ZSH => $home . '/.zshrc',
+ self::BASH => $home . '/.bashrc',
+ };
+ }
+
+ public function completionsDirectory(): string
+ {
+ $home = $this->getHomeDirectory();
+
+ return match ($this) {
+ self::ZSH => $home . '/.zsh/completions',
+ self::BASH => $home . '/.local/share/bash-completion/completions',
+ };
+ }
+
+ public function completionScriptName(): string
+ {
+ return match ($this) {
+ self::ZSH => '_tempest',
+ self::BASH => 'tempest',
+ };
+ }
+
+ public function completionSourceFile(): string
+ {
+ return match ($this) {
+ self::ZSH => 'complete.zsh',
+ self::BASH => 'complete.bash',
+ };
+ }
+
+ public static function detect(): ?self
+ {
+ return match (self::getEnv('SHELL') |> basename(...)) {
+ 'zsh' => self::ZSH,
+ 'bash' => self::BASH,
+ default => null,
+ };
+ }
+
+ private function getHomeDirectory(): string
+ {
+ return self::getEnv('HOME');
+ }
+
+ private static function getEnv(string $name): string
+ {
+ if (is_string($_SERVER[$name] ?? null)) {
+ return $_SERVER[$name];
+ }
+
+ return getenv($name) ?: '';
+ }
+}
diff --git a/packages/console/src/ShellCompletionSupport.php b/packages/console/src/ShellCompletionSupport.php
new file mode 100644
index 000000000..5415ea15c
--- /dev/null
+++ b/packages/console/src/ShellCompletionSupport.php
@@ -0,0 +1,59 @@
+completionSourceFile();
+ }
+
+ private function removeCompletionLines(string $content): string
+ {
+ $lines = explode(PHP_EOL, $content);
+ $result = [];
+ $inCompletionBlock = false;
+
+ foreach ($lines as $line) {
+ if (str($line)->contains(self::COMPLETION_MARKER)) {
+ $inCompletionBlock = true;
+
+ continue;
+ }
+
+ if ($inCompletionBlock) {
+ $trimmed = str($line)->trim();
+
+ if ($trimmed->startsWith('source') || $trimmed->startsWith('fpath=') || $trimmed->startsWith('autoload')) {
+ continue;
+ }
+
+ if ($trimmed->isEmpty()) {
+ $inCompletionBlock = false;
+
+ continue;
+ }
+
+ $inCompletionBlock = false;
+ }
+
+ $result[] = $line;
+ }
+
+ return implode(PHP_EOL, $result);
+ }
+}
diff --git a/packages/console/src/complete.bash b/packages/console/src/complete.bash
new file mode 100644
index 000000000..072a63d12
--- /dev/null
+++ b/packages/console/src/complete.bash
@@ -0,0 +1,44 @@
+bind 'set show-all-if-ambiguous on' 2>/dev/null
+
+_tempest() {
+ local cur cmd requestComp out comp i w
+ local -a words
+ COMPREPLY=()
+
+ cur="${COMP_LINE:0:$COMP_POINT}" cur="${cur##* }"
+ cmd="${COMP_WORDS[0]}"
+
+ read -ra words <<< "${COMP_LINE:0:$COMP_POINT}"
+ [[ "${COMP_LINE:$((COMP_POINT-1)):1}" == " " ]] && words+=("")
+ local wordCount=${#words[@]}
+
+ if [[ "$cmd" == "php" ]]; then
+ [[ "${words[1]:-}" != "tempest" ]] && return
+ requestComp="php tempest _complete --current=$((wordCount-2))"
+ words=("${words[@]:1}")
+ elif [[ "$cmd" == "./tempest" || "$cmd" == *"/tempest" ]]; then
+ requestComp="$cmd _complete --current=$((wordCount-1))"
+ else
+ return
+ fi
+
+ for w in "${words[@]}"; do
+ [[ -n "$w" ]] && i="${i}--input=\"${w}\" "
+ done
+
+ out=$(eval "$requestComp ${i:---input=\" \"}" 2>/dev/null)
+
+ while IFS= read -r comp; do
+ [[ -z "$comp" ]] && continue
+ comp="${comp%% *}"
+ if [[ -z "$cur" ]]; then
+ COMPREPLY+=("$comp")
+ elif [[ "$cur" == *":"* ]]; then
+ [[ "$comp" == "${cur%:*}:"* ]] && COMPREPLY+=("${comp#"${cur%:*}:"}")
+ else
+ [[ "$comp" == "$cur"* ]] && COMPREPLY+=("$comp")
+ fi
+ done <<< "$out"
+}
+
+complete -F _tempest ./tempest php
diff --git a/packages/console/src/complete.zsh b/packages/console/src/complete.zsh
index 92ff620f5..8123aa09f 100644
--- a/packages/console/src/complete.zsh
+++ b/packages/console/src/complete.zsh
@@ -1,44 +1,28 @@
-# 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() {
+_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
+ local startIndex=1 cmd="${words[1]}"
+ if [[ "$cmd" == "php" ]]; then
+ [[ "${words[2]}" != "tempest" ]] && return
+ requestComp="php tempest _complete --current=$((CURRENT-2))"
+ startIndex=2
+ elif [[ "$cmd" == "./tempest" || "$cmd" == *"/tempest" ]]; then
+ requestComp="$cmd _complete --current=$((CURRENT-1))"
+ else
+ return
+ fi
+
+ local i=""
+ for w in ${words[@]:$startIndex}; do
w=$(printf -- '%b' "$w")
- # remove quotes from typed values
quote="${w:0:1}"
if [ "$quote" = \' ]; then
w="${w%\'}"
@@ -47,28 +31,21 @@ _sf_tempest() {
w="${w%\"}"
w="${w#\"}"
fi
- # empty values are ignored
- if [ ! -z "$w" ]; then
+ if [ -n "$w" ]; then
i="${i}--input=\"${w}\" "
fi
done
- # Ensure at least 1 input
- if [ "${i}" = "" ]; then
+ if [ -z "${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/:}
@@ -76,9 +53,10 @@ _sf_tempest() {
fi
done < <(printf "%s\n" "${out[@]}")
- # Let inbuilt _describe handle completions
eval _describe "completions" completions $flagPrefix
return $?
}
-compdef _sf_tempest tempest
+compdef _tempest -p '*/tempest'
+compdef _tempest -p 'tempest'
+compdef _tempest php