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