diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7070e39c60..a416c17170 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,6 +18,7 @@ /packages/clock/ @aidan-casey /packages/command-bus/ @brendt @aidan-casey /packages/console/ @brendt +/packages/console/src/Completion @xHeaven /packages/container/ @brendt /packages/core/ @brendt /packages/cryptography/ @innocenzi diff --git a/composer.json b/composer.json index 7c17954b4a..e81e803e95 100644 --- a/composer.json +++ b/composer.json @@ -237,7 +237,8 @@ } }, "bin": [ - "packages/console/bin/tempest" + "packages/console/bin/tempest", + "packages/console/bin/tempest-complete" ], "config": { "allow-plugins": { diff --git a/docs/1-essentials/04-console-commands.md b/docs/1-essentials/04-console-commands.md index be30b55d98..08d0d5554e 100644 --- a/docs/1-essentials/04-console-commands.md +++ b/docs/1-essentials/04-console-commands.md @@ -240,7 +240,9 @@ Interactive components are only supported on Mac and Linux. On Windows, Tempest ## Shell completion -Tempest provides shell completion for Zsh and Bash. This allows you to press `Tab` to autocomplete command names and options. +Tempest provides shell completion for Zsh and Bash on Linux and macOS. This allows you to press `Tab` to autocomplete command names and options. On Windows, use WSL. + +Completion relies on two things: a **completion script** sourced by your shell, and a **helper executable** (`vendor/bin/tempest-complete`) that performs the actual matching. ### Installing completions @@ -250,28 +252,38 @@ Run the install command and follow the prompts: ./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: +This will: -```zsh -# Add to ~/.zshrc -fpath=(~/.zsh/completions $fpath) -autoload -Uz compinit && compinit -``` +1. Detect your shell (or use `--shell=zsh` / `--shell=bash`). +2. Generate completion metadata (`commands.json`) for all registered commands. +3. Install the completion script to the appropriate location. -For Bash, source the completion file in your `~/.bashrc`: +After installation, add the following line to your shell configuration file and restart your terminal: ```bash -source ~/.bash_completion.d/tempest.bash +# Zsh: add to ~/.zshrc +source ~/.tempest/completion/tempest.zsh + +# Bash: add to ~/.bashrc +source ~/.tempest/completion/tempest.bash ``` -### Additional commands +### Keeping completions up to date + +After adding or removing commands, regenerate the metadata: + +```console +./tempest completion:generate +``` -You may also use these related commands: +### Available commands -- `completion:show` — Output the completion script to stdout (useful for custom installation) -- `completion:uninstall` — Remove the installed completion script +| Command | Description | +|------------------------|--------------------------------------------------------------------------| +| `completion:install` | Install the completion script and generate metadata. | +| `completion:generate` | Regenerate the completion metadata JSON. | +| `completion:show` | Output the completion script to stdout (useful for custom installation). | +| `completion:uninstall` | Remove the installed completion script. | ## Middleware diff --git a/packages/console/.gitignore b/packages/console/.gitignore index 254f5fef43..995cecc535 100644 --- a/packages/console/.gitignore +++ b/packages/console/.gitignore @@ -1,3 +1,3 @@ console.log debug.log -tempest.log \ No newline at end of file +tempest.log diff --git a/packages/console/bin/tempest-complete b/packages/console/bin/tempest-complete new file mode 100755 index 0000000000..a62dbb1bc3 --- /dev/null +++ b/packages/console/bin/tempest-complete @@ -0,0 +1,30 @@ +#!/usr/bin/env php +run(array_slice($_SERVER['argv'] ?? [], 1)); diff --git a/packages/console/composer.json b/packages/console/composer.json index 302e4c6fe8..21f4d7366e 100644 --- a/packages/console/composer.json +++ b/packages/console/composer.json @@ -31,6 +31,7 @@ } }, "bin": [ - "bin/tempest" + "bin/tempest", + "bin/tempest-complete" ] } diff --git a/packages/console/src/Actions/BuildCompletionMetadata.php b/packages/console/src/Actions/BuildCompletionMetadata.php new file mode 100644 index 0000000000..11bdef49f9 --- /dev/null +++ b/packages/console/src/Actions/BuildCompletionMetadata.php @@ -0,0 +1,103 @@ +consoleConfig->commands as $name => $command) { + $flags = array_map( + fn (ConsoleArgumentDefinition $definition): array => [ + 'name' => $definition->name, + 'flag' => $this->buildFlagNotation($definition), + 'aliases' => $this->buildFlagAliases($definition), + 'description' => $definition->description, + 'value_options' => $this->buildValueOptions($definition), + 'repeatable' => $definition->type === 'array' || $definition->isVariadic, + 'requires_value' => $definition->type !== 'bool', + ], + $command->getArgumentDefinitions(), + ); + + usort($flags, static fn (array $a, array $b): int => $a['flag'] <=> $b['flag']); + + $commands[$name] = [ + 'hidden' => $command->hidden, + 'description' => $command->description, + 'flags' => $flags, + ]; + } + + ksort($commands); + + return [ + 'version' => 1, + 'commands' => $commands, + ]; + } + + private function buildFlagNotation(ConsoleArgumentDefinition $definition): string + { + $flag = "--{$definition->name}"; + + if ($definition->type !== 'bool') { + $flag .= '='; + } + + return $flag; + } + + private function buildFlagAliases(ConsoleArgumentDefinition $definition): array + { + $aliases = array_values(array_filter(array_map(static function (string $alias): ?string { + $normalized = ltrim(str($alias)->trim()->kebab()->toString(), '-'); + + if ($normalized === '') { + return null; + } + + return match (strlen($normalized)) { + 1 => "-{$normalized}", + default => "--{$normalized}", + }; + }, $definition->aliases))); + + sort($aliases); + + return $aliases; + } + + private function buildValueOptions(ConsoleArgumentDefinition $definition): array + { + if (! $definition->isBackedEnum()) { + return []; + } + + /** @var class-string $type */ + $type = $definition->type; + + $options = array_map( + static fn (BackedEnum $case): string => (string) $case->value, + $type::cases(), + ); + + sort($options); + + return $options; + } +} diff --git a/packages/console/src/Commands/CompletionGenerateCommand.php b/packages/console/src/Commands/CompletionGenerateCommand.php new file mode 100644 index 0000000000..d62bf88247 --- /dev/null +++ b/packages/console/src/Commands/CompletionGenerateCommand.php @@ -0,0 +1,48 @@ +completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + + $path ??= $this->completionRuntime->getMetadataPath(); + + Filesystem\write_json($path, ($this->buildCompletionMetadata)(), pretty: false); + + $this->console->success("Wrote completion metadata to: {$path}"); + + return ExitCode::SUCCESS; + } +} diff --git a/packages/console/src/Commands/CompletionInstallCommand.php b/packages/console/src/Commands/CompletionInstallCommand.php index 73e607c0a5..b70a7c0ce7 100644 --- a/packages/console/src/Commands/CompletionInstallCommand.php +++ b/packages/console/src/Commands/CompletionInstallCommand.php @@ -5,12 +5,15 @@ namespace Tempest\Console\Commands; use Symfony\Component\Filesystem\Path; +use Tempest\Console\Actions\BuildCompletionMetadata; use Tempest\Console\Actions\ResolveShell; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; use Tempest\Console\Enums\Shell; use Tempest\Console\ExitCode; +use Tempest\Console\Middleware\ForceMiddleware; use Tempest\Support\Filesystem; use function Tempest\Support\path; @@ -19,12 +22,15 @@ { public function __construct( private Console $console, + private CompletionRuntime $completionRuntime, private ResolveShell $resolveShell, + private BuildCompletionMetadata $buildCompletionMetadata, ) {} #[ConsoleCommand( name: 'completion:install', description: 'Install shell completion for Tempest', + middleware: [ForceMiddleware::class], )] public function __invoke( #[ConsoleArgument( @@ -32,12 +38,13 @@ public function __invoke( aliases: ['-s'], )] ?Shell $shell = null, - #[ConsoleArgument( - description: 'Skip confirmation prompts', - aliases: ['-f'], - )] - bool $force = false, ): ExitCode { + if (! $this->completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + $shell ??= ($this->resolveShell)('Which shell do you want to install completions for?'); if ($shell === null) { @@ -47,8 +54,8 @@ public function __invoke( } $sourcePath = $this->getSourcePath($shell); - $targetDir = $shell->getCompletionsDirectory(); - $targetPath = $shell->getInstalledCompletionPath(); + $targetDir = $this->completionRuntime->getInstallationDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath($shell); if (! Filesystem\is_file($sourcePath)) { $this->console->error("Completion script not found: {$sourcePath}"); @@ -56,7 +63,7 @@ public function __invoke( return ExitCode::ERROR; } - if (! $force) { + if (! $this->console->isForced) { $this->console->info("Installing {$shell->value} completions"); $this->console->keyValue('Source', $sourcePath); $this->console->keyValue('Target', $targetPath); @@ -69,22 +76,24 @@ public function __invoke( } } + Filesystem\write_json($this->completionRuntime->getMetadataPath(), ($this->buildCompletionMetadata)(), pretty: false); + 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.'); + if (Filesystem\is_file($targetPath) && ! $this->console->confirm('Completion file already exists. Overwrite?', default: true)) { + $this->console->warning('Installation cancelled.'); - return ExitCode::CANCELLED; - } + return ExitCode::CANCELLED; } - Filesystem\copy_file($sourcePath, $targetPath, overwrite: true); + $script = Filesystem\read_file($sourcePath); + Filesystem\write_file($targetPath, $script); + $this->console->success("Installed completion script to: {$targetPath}"); $this->console->writeln(); $this->console->info('Next steps:'); - $this->console->instructions($shell->getPostInstallInstructions()); + $this->console->instructions($this->completionRuntime->getPostInstallInstructions($shell)); return ExitCode::SUCCESS; } diff --git a/packages/console/src/Commands/CompletionShowCommand.php b/packages/console/src/Commands/CompletionShowCommand.php index 1afeb32213..325e8b03a1 100644 --- a/packages/console/src/Commands/CompletionShowCommand.php +++ b/packages/console/src/Commands/CompletionShowCommand.php @@ -6,6 +6,7 @@ use Symfony\Component\Filesystem\Path; use Tempest\Console\Actions\ResolveShell; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; @@ -19,6 +20,7 @@ { public function __construct( private Console $console, + private CompletionRuntime $completionRuntime, private ResolveShell $resolveShell, ) {} @@ -33,6 +35,12 @@ public function __invoke( )] ?Shell $shell = null, ): ExitCode { + if (! $this->completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + $shell ??= ($this->resolveShell)('Which shell completion script do you want to see?'); if ($shell === null) { diff --git a/packages/console/src/Commands/CompletionUninstallCommand.php b/packages/console/src/Commands/CompletionUninstallCommand.php index 7e0a0e61cd..5c4f1fd228 100644 --- a/packages/console/src/Commands/CompletionUninstallCommand.php +++ b/packages/console/src/Commands/CompletionUninstallCommand.php @@ -5,23 +5,27 @@ namespace Tempest\Console\Commands; use Tempest\Console\Actions\ResolveShell; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; use Tempest\Console\Enums\Shell; use Tempest\Console\ExitCode; +use Tempest\Console\Middleware\ForceMiddleware; use Tempest\Support\Filesystem; final readonly class CompletionUninstallCommand { public function __construct( private Console $console, + private CompletionRuntime $completionRuntime, private ResolveShell $resolveShell, ) {} #[ConsoleCommand( name: 'completion:uninstall', description: 'Uninstall shell completion for Tempest', + middleware: [ForceMiddleware::class], )] public function __invoke( #[ConsoleArgument( @@ -29,12 +33,13 @@ public function __invoke( aliases: ['-s'], )] ?Shell $shell = null, - #[ConsoleArgument( - description: 'Skip confirmation prompts', - aliases: ['-f'], - )] - bool $force = false, ): ExitCode { + if (! $this->completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + $shell ??= ($this->resolveShell)('Which shell completions do you want to uninstall?'); if ($shell === null) { @@ -43,7 +48,7 @@ public function __invoke( return ExitCode::ERROR; } - $targetPath = $shell->getInstalledCompletionPath(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath($shell); if (! Filesystem\is_file($targetPath)) { $this->console->warning("Completion file not found: {$targetPath}"); @@ -52,7 +57,7 @@ public function __invoke( return ExitCode::SUCCESS; } - if (! $force) { + if (! $this->console->isForced) { $this->console->info("Uninstalling {$shell->value} completions"); $this->console->keyValue('File', $targetPath); $this->console->writeln(); @@ -67,40 +72,10 @@ public function __invoke( Filesystem\delete_file($targetPath); $this->console->success("Removed completion script: {$targetPath}"); - if ($shell === Shell::ZSH) { - $this->cleanupZshCache(); - } - $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; } - - private function cleanupZshCache(): void - { - $home = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? null; - - if ($home === null) { - return; - } - - $cacheFiles = glob("{$home}/.zcompdump*") ?: []; - - foreach ($cacheFiles as $file) { - Filesystem\delete_file($file); - } - - if ($cacheFiles !== []) { - $this->console->info('Cleared zsh completion cache (~/.zcompdump*)'); - } - - $this->console->writeln(); - $this->console->info('Run this to clear completions in your current shell:'); - $this->console->writeln(); - $this->console->writeln(" unset '_patcomps[php]' '_patcomps[tempest]' '_patcomps[*/tempest]' 2>/dev/null"); - $this->console->writeln(); - $this->console->info('Or restart your shell: exec zsh'); - } } diff --git a/packages/console/src/Completion/CompletionApplication.php b/packages/console/src/Completion/CompletionApplication.php new file mode 100644 index 0000000000..263f56fb66 --- /dev/null +++ b/packages/console/src/Completion/CompletionApplication.php @@ -0,0 +1,58 @@ +argumentParser->parse($args); + + if (! $parsedArguments instanceof CompletionArguments) { + return; + } + + $input = $this->inputNormalizer->normalize($parsedArguments->words, $parsedArguments->currentIndex); + + if (! $input instanceof CompletionInput) { + return; + } + + $content = $this->metadataFileReader->read($parsedArguments->metadataPath); + + if (! is_string($content)) { + return; + } + + $metadata = $this->metadataParser->parseJson($content); + + if (! $metadata instanceof CompletionMetadata) { + return; + } + + $completions = $this->engine->complete($metadata, $input); + + if ($completions === []) { + return; + } + + $output = $this->outputFormatter->format($completions); + + if ($output === '') { + return; + } + + fwrite(STDOUT, $output); + } +} diff --git a/packages/console/src/Completion/CompletionArgumentParser.php b/packages/console/src/Completion/CompletionArgumentParser.php new file mode 100644 index 0000000000..3f268d5a4a --- /dev/null +++ b/packages/console/src/Completion/CompletionArgumentParser.php @@ -0,0 +1,24 @@ +flagNameLookup[$value] ?? null; + } +} diff --git a/packages/console/src/Completion/CompletionDescriptionSanitizer.php b/packages/console/src/Completion/CompletionDescriptionSanitizer.php new file mode 100644 index 0000000000..46daa59f52 --- /dev/null +++ b/packages/console/src/Completion/CompletionDescriptionSanitizer.php @@ -0,0 +1,23 @@ +words === []) { + return []; + } + + $current = $input->words[$input->currentIndex] ?? ''; + + if ($input->currentIndex <= 1) { + return $this->completeCommands($metadata, $current); + } + + $commandName = $input->words[1] ?? ''; + + if (str_starts_with($commandName, '-')) { + return $this->completeCommands($metadata, $current); + } + + $command = $metadata->findCommand($commandName); + + if (! $command instanceof CompletionCommand) { + return []; + } + + return $this->completeFlags($command, $input->words, $input->currentIndex, $current); + } + + private function completeCommands(CompletionMetadata $metadata, string $current): array + { + if (str_starts_with($current, '-')) { + return []; + } + + $maxNameLength = 0; + $candidates = []; + + foreach ($metadata->commands as $name => $command) { + if (! is_string($name) || ! $command instanceof CompletionCommand) { + continue; + } + + if ($command->hidden) { + continue; + } + + if (! str_starts_with($name, $current)) { + continue; + } + + $maxNameLength = max($maxNameLength, strlen($name)); + $candidates[] = [$name, $this->descriptionSanitizer->sanitize($command->description)]; + } + + $completions = []; + + foreach ($candidates as [$name, $description]) { + $completions[] = new CompletionCandidate( + value: $name, + display: $description === null + ? null + : sprintf("%-{$maxNameLength}s %s", $name, $description), + ); + } + + return $completions; + } + + private function completeFlags(CompletionCommand $command, array $words, int $currentIndex, string $current): array + { + if ($current !== '' && ! str_starts_with($current, '-')) { + return []; + } + + $usedFlags = $this->collectUsedFlags($command, $words, $currentIndex); + $maxLabelLength = 0; + $candidates = []; + + foreach ($command->flags as $flag) { + if (! $flag instanceof CompletionFlag) { + continue; + } + + if (! $flag->repeatable && isset($usedFlags[$flag->name])) { + continue; + } + + $value = $this->selectCompletionValue($flag, $current); + + if ($value === null) { + continue; + } + + $label = $this->buildFlagLabel($flag); + $description = $this->descriptionSanitizer->sanitize($flag->description); + + $maxLabelLength = max($maxLabelLength, strlen($label)); + $candidates[] = [$value, $label, $description]; + } + + $completions = []; + + foreach ($candidates as [$value, $label, $description]) { + $completions[] = new CompletionCandidate( + value: $value, + display: $description === null + ? $label + : sprintf("%-{$maxLabelLength}s %s", $label, $description), + ); + } + + return $completions; + } + + private function selectCompletionValue(CompletionFlag $flag, string $current): ?string + { + $candidates = []; + + if (str_starts_with($current, '--')) { + $candidates[] = $flag->flag; + + foreach ($flag->aliases as $alias) { + if (str_starts_with($alias, '--')) { + $candidates[] = $alias; + } + } + } elseif (str_starts_with($current, '-')) { + foreach ($flag->aliases as $alias) { + $candidates[] = $alias; + } + + $candidates[] = $flag->flag; + } else { + $candidates[] = $flag->flag; + } + + return array_find($candidates, static fn ($candidate) => str_starts_with($candidate, $current)); + } + + private function buildFlagLabel(CompletionFlag $flag): string + { + $label = $flag->flag; + + if ($flag->valueOptions !== [] && str_ends_with($flag->flag, '=')) { + $label .= '<' . implode(',', $flag->valueOptions) . '>'; + } + + if ($flag->aliases !== []) { + $label .= ' / ' . implode(' / ', $flag->aliases); + } + + return $label; + } + + private function collectUsedFlags(CompletionCommand $command, array $words, int $currentIndex): array + { + $used = []; + + for ($index = 2, $max = count($words); $index < $max; $index++) { + if ($index === $currentIndex) { + continue; + } + + $word = $words[$index] ?? null; + + if (! is_string($word)) { + continue; + } + + if (str_starts_with($word, '--')) { + $name = $this->normalizeLongFlag($word); + + if ($name === null) { + continue; + } + + $flagName = $command->resolveFlagName($name); + + if ($flagName !== null) { + $used[$flagName] = true; + } + + continue; + } + + if (! str_starts_with($word, '-')) { + continue; + } + + $shortValue = explode('=', $word, 2)[0]; + $shortValue = ltrim($shortValue, '-'); + + if (strlen($shortValue) === 1) { + $flagName = $command->resolveFlagName($shortValue); + + if ($flagName !== null) { + $used[$flagName] = true; + } + + continue; + } + + foreach (str_split($shortValue) as $part) { + $flagName = $command->resolveFlagName($part); + + if ($flagName !== null) { + $used[$flagName] = true; + } + } + } + + return $used; + } + + private function normalizeLongFlag(string $value): ?string + { + $normalized = $value; + + while (str_starts_with($normalized, '--')) { + $normalized = substr($normalized, 2); + } + + $normalized = explode('=', $normalized, 2)[0]; + + if (str_starts_with($normalized, 'no-')) { + $normalized = substr($normalized, 3); + } + + if ($normalized === '') { + return null; + } + + return $normalized; + } +} diff --git a/packages/console/src/Completion/CompletionFlag.php b/packages/console/src/Completion/CompletionFlag.php new file mode 100644 index 0000000000..28b0e4ffa2 --- /dev/null +++ b/packages/console/src/Completion/CompletionFlag.php @@ -0,0 +1,17 @@ +normalizeWords($words); + + if ($this->invocationInspector->isPhpBinary($normalizedWords[0])) { + if (count($normalizedWords) < 2 || ! $this->invocationInspector->isTempestInvocation($normalizedWords[1])) { + return null; + } + + array_shift($normalizedWords); + $currentIndex = max(0, $currentIndex - 1); + } + + if ($normalizedWords === [] || ! $this->invocationInspector->isTempestInvocation($normalizedWords[0])) { + return null; + } + + if ($currentIndex >= count($normalizedWords)) { + $normalizedWords[] = ''; + } + + $currentIndex = min($currentIndex, count($normalizedWords) - 1); + + return new CompletionInput( + words: $normalizedWords, + currentIndex: $currentIndex, + ); + } + + private function normalizeWords(array $words): array + { + $normalizedWords = []; + + foreach ($words as $word) { + $normalizedWords[] = is_string($word) ? $word : ''; + } + + return $normalizedWords; + } +} diff --git a/packages/console/src/Completion/CompletionInvocationInspector.php b/packages/console/src/Completion/CompletionInvocationInspector.php new file mode 100644 index 0000000000..3f3fbd2b92 --- /dev/null +++ b/packages/console/src/Completion/CompletionInvocationInspector.php @@ -0,0 +1,25 @@ +basename($value) === 'php'; + } + + public function isTempestInvocation(string $value): bool + { + return $this->basename($value) === 'tempest'; + } + + private function basename(string $value): string + { + $basename = basename($value); + + return $basename === '' ? $value : $basename; + } +} diff --git a/packages/console/src/Completion/CompletionMetadata.php b/packages/console/src/Completion/CompletionMetadata.php new file mode 100644 index 0000000000..bbebed2c52 --- /dev/null +++ b/packages/console/src/Completion/CompletionMetadata.php @@ -0,0 +1,19 @@ +commands[$name] ?? null; + + return $command instanceof CompletionCommand ? $command : null; + } +} diff --git a/packages/console/src/Completion/CompletionMetadataFileReader.php b/packages/console/src/Completion/CompletionMetadataFileReader.php new file mode 100644 index 0000000000..ba09e8af77 --- /dev/null +++ b/packages/console/src/Completion/CompletionMetadataFileReader.php @@ -0,0 +1,21 @@ +parse($metadata); + } + + public function parse(mixed $metadata): ?CompletionMetadata + { + if (! is_array($metadata)) { + return null; + } + + $commandsMetadata = $metadata['commands'] ?? []; + + if (! is_array($commandsMetadata)) { + return null; + } + + $commands = []; + + foreach ($commandsMetadata as $name => $commandMetadata) { + if (! is_string($name) || ! is_array($commandMetadata)) { + return null; + } + + $command = $this->parseCommand($commandMetadata); + + if (! $command instanceof CompletionCommand) { + return null; + } + + $commands[$name] = $command; + } + + ksort($commands); + + return new CompletionMetadata($commands); + } + + private function parseCommand(array $command): ?CompletionCommand + { + $hidden = $command['hidden'] ?? false; + + if (! is_bool($hidden)) { + return null; + } + + $description = $command['description'] ?? null; + + if (! is_string($description) && $description !== null) { + return null; + } + + $flags = $this->parseFlags($command['flags'] ?? []); + + if (! is_array($flags)) { + return null; + } + + return new CompletionCommand( + hidden: $hidden, + description: $description, + flags: $flags, + flagNameLookup: $this->buildFlagNameLookup($flags), + ); + } + + private function parseFlags(mixed $flagsMetadata): ?array + { + if (! is_array($flagsMetadata)) { + return null; + } + + $flags = []; + + foreach ($flagsMetadata as $flagMetadata) { + if (! is_array($flagMetadata)) { + return null; + } + + $flag = $this->parseFlag($flagMetadata); + + if (! $flag instanceof CompletionFlag) { + return null; + } + + $flags[] = $flag; + } + + return $flags; + } + + private function parseFlag(array $flag): ?CompletionFlag + { + $name = $flag['name'] ?? null; + $notation = $flag['flag'] ?? null; + $repeatable = $flag['repeatable'] ?? null; + + if (! is_string($name) || ! is_string($notation) || ! is_bool($repeatable)) { + return null; + } + + $aliases = $this->parseStringList($flag['aliases'] ?? []); + + if (! is_array($aliases)) { + return null; + } + + $description = $flag['description'] ?? null; + + if (! is_string($description) && $description !== null) { + return null; + } + + $valueOptions = $this->parseStringList($flag['value_options'] ?? []); + + if (! is_array($valueOptions)) { + return null; + } + + return new CompletionFlag( + name: $name, + flag: $notation, + aliases: $aliases, + description: $description, + valueOptions: $valueOptions, + repeatable: $repeatable, + ); + } + + private function parseStringList(mixed $values): ?array + { + if (! is_array($values)) { + return null; + } + + $strings = []; + + foreach ($values as $value) { + if (! is_string($value)) { + return null; + } + + $strings[] = $value; + } + + return $strings; + } + + private function buildFlagNameLookup(array $flags): array + { + $lookup = []; + + foreach ($flags as $flag) { + $lookup[$flag->name] = $flag->name; + + $normalizedFlag = $this->normalizeFlagLookupValue($flag->flag); + + if ($normalizedFlag !== null) { + $lookup[$normalizedFlag] = $flag->name; + } + + foreach ($flag->aliases as $alias) { + $normalizedAlias = $this->normalizeFlagLookupValue($alias); + + if ($normalizedAlias !== null) { + $lookup[$normalizedAlias] = $flag->name; + } + } + } + + return $lookup; + } + + private function normalizeFlagLookupValue(string $value): ?string + { + $normalizedValue = ltrim($value, '-'); + $normalizedValue = rtrim($normalizedValue, '='); + + if (str_starts_with($normalizedValue, 'no-')) { + $normalizedValue = substr($normalizedValue, 3); + } + + if ($normalizedValue === '') { + return null; + } + + return $normalizedValue; + } +} diff --git a/packages/console/src/Completion/CompletionOutputFormatter.php b/packages/console/src/Completion/CompletionOutputFormatter.php new file mode 100644 index 0000000000..faa7c1b7d9 --- /dev/null +++ b/packages/console/src/Completion/CompletionOutputFormatter.php @@ -0,0 +1,25 @@ +display === null + ? $completion->value + : "{$completion->value}\t{$completion->display}"; + } + + return implode("\n", $output); + } +} diff --git a/packages/console/src/CompletionRuntime.php b/packages/console/src/CompletionRuntime.php new file mode 100644 index 0000000000..adbc4720e8 --- /dev/null +++ b/packages/console/src/CompletionRuntime.php @@ -0,0 +1,88 @@ +getProfileDirectory(), '.tempest', 'completion')->toString()); + } + + public function getMetadataPath(): string + { + return internal_storage_path('completion', 'commands.json'); + } + + public function isSupportedPlatform(?string $osFamily = null): bool + { + $osFamily ??= PHP_OS_FAMILY; + + return match ($osFamily) { + 'Darwin', 'Linux' => true, + default => false, + }; + } + + public function getUnsupportedPlatformMessage(): string + { + return 'Completion commands are supported on Linux and macOS. Use WSL if you are on Windows.'; + } + + public function getInstalledCompletionPath(Shell $shell): string + { + return $this->getInstallationDirectory() . '/' . $shell->getCompletionFilename(); + } + + /** + * @return string[] + */ + public function getPostInstallInstructions(Shell $shell): array + { + $rcFile = $shell->getRcFile(); + $installedPath = $this->getInstalledCompletionPath($shell); + + return match ($shell) { + Shell::ZSH => [ + "Add this line to {$rcFile} and restart your terminal:", + '', + " source {$installedPath}", + ], + Shell::BASH => [ + "Add this line to {$rcFile} and restart your terminal:", + '', + " source {$installedPath}", + ], + }; + } + + private function getProfileDirectory(): string + { + $profileDirectory = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? getenv('HOME') ?: null; + + if ($profileDirectory === null) { + $profileDirectory = $_SERVER['USERPROFILE'] ?? $_ENV['USERPROFILE'] ?? getenv('USERPROFILE') ?: null; + } + + if ($profileDirectory === null && getenv('HOMEDRIVE') !== false && getenv('HOMEPATH') !== false) { + $profileDirectory = getenv('HOMEDRIVE') . getenv('HOMEPATH'); + } + + if ($profileDirectory === null) { + throw new RuntimeException('Could not determine user profile directory for completions.'); + } + + return $profileDirectory; + } +} diff --git a/packages/console/src/Enums/Shell.php b/packages/console/src/Enums/Shell.php index ddf4ae28df..3d4a5e0542 100644 --- a/packages/console/src/Enums/Shell.php +++ b/packages/console/src/Enums/Shell.php @@ -24,29 +24,14 @@ public static function detect(): ?self }; } - 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::ZSH => 'tempest.zsh', self::BASH => 'tempest.bash', }; } - public function getInstalledCompletionPath(): string - { - return $this->getCompletionsDirectory() . '/' . $this->getCompletionFilename(); - } - public function getSourceFilename(): string { return match ($this) { @@ -64,31 +49,4 @@ public function getRcFile(): string 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/completion.bash b/packages/console/src/completion.bash index b700571e41..83644916bb 100644 --- a/packages/console/src/completion.bash +++ b/packages/console/src/completion.bash @@ -1,45 +1,194 @@ -# Tempest Framework Bash Completion -# Supports: ./tempest, tempest, php tempest, php vendor/bin/tempest, etc. +_tempest_project_directory() { + local command="$1" + local command_directory -_tempest() { - local cur tempest_cmd shift_count input_args output IFS + command_directory="$(dirname "$command")" - # 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]}" + if [[ "$command" == "vendor/bin/tempest" || "$command" == */vendor/bin/tempest ]]; then + cd "$command_directory/../.." 2>/dev/null && pwd -P + return 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 + cd "$command_directory" 2>/dev/null && pwd -P +} + +COMP_WORDBREAKS="${COMP_WORDBREAKS//:}" + +_tempest_php_passthrough() { + local fallback_function + + [[ -n "${__tempest_php_original_completion:-}" ]] || return 1 + + if [[ "$__tempest_php_original_completion" =~ -F[[:space:]]+([^[:space:]]+) ]]; then + fallback_function="${BASH_REMATCH[1]}" + + if [[ "$fallback_function" != "_tempest" ]] && declare -F "$fallback_function" >/dev/null 2>&1; then + "$fallback_function" + return $? + fi 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]}\"" + return 1 +} + +_tempest_has_compopt() { + type compopt >/dev/null 2>&1 +} + +_tempest_disable_default_completion() { + _tempest_has_compopt || return 0 + + compopt +o default +o bashdefault +} + +_tempest_enable_nospace_for_assignment_candidates() { + local candidate + + _tempest_has_compopt || return 0 + + for candidate in "${COMPREPLY[@]}"; do + if [[ "$candidate" == *= ]]; then + compopt -o nospace + break + fi done +} - # Execute completion command - output=$(eval "$tempest_cmd _complete $input_args" 2>/dev/null) || return 0 - [[ -z "$output" ]] && return 0 +_tempest_resolve_command() { + local -a line_words=("$@") + local command command_name passthrough_status + + if [[ "${line_words[0]}" == "php" ]]; then + if (( ${#line_words[@]} < 2 )) || [[ -z "${line_words[1]}" ]]; then + _tempest_php_passthrough + passthrough_status=$? + REPLY="$passthrough_status" + return 3 + fi + + command="${line_words[1]}" + command_name="${command##*/}" + + if [[ "$command_name" != "tempest" ]]; then + _tempest_php_passthrough + passthrough_status=$? + REPLY="$passthrough_status" + return 3 + fi + else + command="${line_words[0]}" + command_name="${command##*/}" + fi + + [[ "$command_name" == "tempest" ]] || return 2 + + REPLY="$command" + return 0 +} + +_tempest_segment_prefix() { + local current_word="$1" + local current_segment="$2" - # Parse and filter completions - IFS=$'\n' - COMPREPLY=($(compgen -W "$output" -- "$cur")) + REPLY='' - # Suppress trailing space for flags expecting values (bash 4.0+) - if [[ ${#COMPREPLY[@]} -eq 1 && "${COMPREPLY[0]}" == *= ]] && type compopt &>/dev/null; then - compopt -o nospace + # COMP_WORDS may point at only the segment being completed around '=' or ':'. + if [[ "$current_word" == *[=:]* ]] && [[ "$current_segment" != "$current_word" ]]; then + if [[ -z "$current_segment" || "$current_segment" == "=" ]]; then + REPLY="$current_word" + elif [[ -n "$current_segment" ]]; then + REPLY="${current_word%"$current_segment"}" + fi fi } -complete -F _tempest ./tempest -complete -F _tempest tempest -complete -F _tempest php +_tempest_collect_candidates() { + local output="$1" + local segment_prefix="$2" + local candidate tab + + COMPREPLY=() + + tab=$'\t' + + while IFS= read -r candidate; do + if [[ "$candidate" == *"$tab"* ]]; then + candidate="${candidate%%$tab*}" + fi + + if [[ -n "$segment_prefix" ]] && [[ "$candidate" == "$segment_prefix"* ]]; then + candidate="${candidate#$segment_prefix}" + fi + + [[ -z "$candidate" ]] && continue + COMPREPLY+=("$candidate") + done <<< "$output" +} + +_tempest() { + local command project_directory helper metadata output line_prefix current_index current_word current_segment segment_prefix status + local -a line_words + + line_prefix="${COMP_LINE:0:COMP_POINT}" + read -r -a line_words <<< "$line_prefix" + + if [[ "$line_prefix" == *[[:space:]] ]]; then + line_words+=("") + fi + + (( ${#line_words[@]} > 0 )) || return 0 + + _tempest_resolve_command "${line_words[@]}" + status=$? + + case $status in + 0) + command="$REPLY" + ;; + 3) + return "$REPLY" + ;; + *) + return 0 + ;; + esac + + local COMP_WORDBREAKS="${COMP_WORDBREAKS//:}" + + project_directory="$(_tempest_project_directory "$command")" || return 0 + + helper="$project_directory/vendor/bin/tempest-complete" + metadata="$project_directory/.tempest/completion/commands.json" + + [[ -x "$helper" ]] || return 0 + [[ -f "$metadata" ]] || return 0 + + _tempest_disable_default_completion + + current_index=$(( ${#line_words[@]} - 1 )) + current_word="${line_words[$current_index]}" + current_segment="${COMP_WORDS[COMP_CWORD]:-}" + + _tempest_segment_prefix "$current_word" "$current_segment" + segment_prefix="$REPLY" + + output="$($helper "$metadata" "$current_index" "${line_words[@]}" 2>/dev/null)" || return 0 + [[ -z "$output" ]] && return 0 + + _tempest_collect_candidates "$output" "$segment_prefix" + _tempest_enable_nospace_for_assignment_candidates +} + +if [[ -z "${__tempest_php_original_completion:-}" ]] && complete -p php >/dev/null 2>&1; then + __tempest_php_original_completion="$(complete -p php 2>/dev/null)" + + if [[ "$__tempest_php_original_completion" == *"-F _tempest"* ]]; then + __tempest_php_original_completion='' + fi +fi + +complete -o bashdefault -o default -F _tempest tempest +complete -o bashdefault -o default -F _tempest ./tempest +complete -o bashdefault -o default -F _tempest ./vendor/bin/tempest +complete -o bashdefault -o default -F _tempest vendor/bin/tempest +complete -o bashdefault -o default -F _tempest php diff --git a/packages/console/src/completion.zsh b/packages/console/src/completion.zsh index 1017276ef0..b400e8e1d2 100644 --- a/packages/console/src/completion.zsh +++ b/packages/console/src/completion.zsh @@ -1,54 +1,151 @@ -#compdef -p '*/tempest' -p 'tempest' php +if ! (( $+functions[compdef] )); then + autoload -Uz compinit 2>/dev/null + compinit -i 2>/dev/null +fi -# Tempest Framework Zsh Completion +if (( $+functions[compdef] )); then + if [[ -z "${_tempest_php_original_compdef:-}" ]] && [[ -n "${_comps[php]:-}" ]] && [[ "${_comps[php]}" != "_tempest" ]]; then + typeset -g _tempest_php_original_compdef="${_comps[php]}" + fi -_tempest() { - local current="${words[CURRENT]}" tempest_cmd shift_count output - local -a args with_suffix without_suffix + _tempest_php_passthrough() { + local service=php - # 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 + if [[ -n "${_tempest_php_original_compdef:-}" ]] && (( $+functions[$_tempest_php_original_compdef] )); then + "$_tempest_php_original_compdef" + return $? + 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//:/\\:}") + if (( $+functions[_default] )); then + _default + return $? fi - done - # Add completions: no trailing space for "=" options, use _describe for commands - (( ${#without_suffix} )) && compadd -Q -S '' -- "${without_suffix[@]}" + return 1 + } + + _tempest_resolve_command() { + local command command_name passthrough_status - if (( ${#with_suffix} )); then - if [[ "$current" == -* || "${with_suffix[1]}" == -* ]]; then - compadd -Q -- "${with_suffix[@]}" + if [[ "${words[1]}" == "php" ]]; then + if (( ${#words[@]} < 2 )) || [[ -z "${words[2]}" ]]; then + _tempest_php_passthrough + passthrough_status=$? + REPLY="$passthrough_status" + return 3 + fi + + command="${words[2]}" + command_name="${command:t}" + + if [[ "$command_name" != "tempest" ]]; then + _tempest_php_passthrough + passthrough_status=$? + REPLY="$passthrough_status" + return 3 + fi else - _describe -t commands 'tempest commands' with_suffix + command="${words[1]}" + command_name="${command:t}" fi - fi -} -compdef _tempest -p '*/tempest' -compdef _tempest -p 'tempest' -compdef _tempest php + [[ "$command_name" == "tempest" ]] || return 2 + + REPLY="$command" + return 0 + } + + _tempest_project_directory() { + local command="$1" + + if [[ "$command" == "vendor/bin/tempest" || "$command" == */vendor/bin/tempest ]]; then + REPLY="${command:h:h:A}" + return 0 + fi + + REPLY="${command:h:A}" + return 0 + } + + _tempest_add_completions() { + local output="$1" + local candidate completion_value completion_display tab + local -a completions with_equals_values with_equals_display without_equals_values without_equals_display + + completions=("${(@f)output}") + tab=$'\t' + + for candidate in "${completions[@]}"; do + [[ -z "$candidate" ]] && continue + + completion_value="${candidate%%$tab*}" + completion_display="$completion_value" + + # Helper entries can be "valuedescription". + if [[ "$candidate" == *"$tab"* ]]; then + completion_display="${candidate#*$tab}" + fi + + [[ -z "$completion_value" ]] && continue + + if [[ "$completion_value" == *= ]]; then + with_equals_values+=("$completion_value") + with_equals_display+=("$completion_display") + else + without_equals_values+=("$completion_value") + without_equals_display+=("$completion_display") + fi + done + + if (( ${#without_equals_values[@]} )); then + compadd -Q -l -d without_equals_display -- "${without_equals_values[@]}" + fi + + if (( ${#with_equals_values[@]} )); then + compadd -Q -l -d with_equals_display -S '' -- "${with_equals_values[@]}" + fi + } + + _tempest() { + local command project_directory helper metadata output resolve_status + + _tempest_resolve_command + resolve_status=$? + + case "$resolve_status" in + 0) + command="$REPLY" + ;; + 3) + return "$REPLY" + ;; + *) + return 0 + ;; + esac + + _compskip=all + + _tempest_project_directory "$command" + project_directory="$REPLY" + + helper="${project_directory}/vendor/bin/tempest-complete" + metadata="${project_directory}/.tempest/completion/commands.json" + + [[ -x "$helper" ]] || return 0 + [[ -f "$metadata" ]] || return 0 + + output="$($helper "$metadata" "$((CURRENT - 1))" "${words[@]}" 2>/dev/null)" || return 0 + [[ -z "$output" ]] && return 0 + + _tempest_add_completions "$output" + return 0 + } + + compdef _tempest ./tempest + compdef _tempest vendor/bin/tempest + compdef _tempest ./vendor/bin/tempest + compdef _tempest php + compdef _tempest -p '*/tempest' + compdef _tempest -p '*/vendor/bin/tempest' +fi diff --git a/packages/console/tests/CompletionHelperPhpTest.php b/packages/console/tests/CompletionHelperPhpTest.php new file mode 100644 index 0000000000..4940f60954 --- /dev/null +++ b/packages/console/tests/CompletionHelperPhpTest.php @@ -0,0 +1,238 @@ +markTestSkipped('Shell completion is not supported on Windows.'); + } + + $this->engine = new CompletionEngine(); + $this->inputNormalizer = new CompletionInputNormalizer(); + $this->metadataParser = new CompletionMetadataParser(); + } + + #[Test] + public function entrypoint_is_executable_php_script(): void + { + $entrypoint = __DIR__ . '/../bin/tempest-complete'; + + $content = file_get_contents($entrypoint); + + $this->assertIsString($content); + $this->assertStringStartsWith("#!/usr/bin/env php\nparseMetadata([ + 'commands' => [ + 'about' => [ + 'hidden' => false, + 'description' => " About\n\tcommand ", + 'flags' => [], + ], + 'cache:clear' => [ + 'hidden' => false, + 'description' => null, + 'flags' => [], + ], + 'hidden:command' => [ + 'hidden' => true, + 'description' => 'Should not be visible', + 'flags' => [], + ], + ], + ]); + + $input = $this->inputNormalizer->normalize(['tempest', ''], 1); + + $this->assertInstanceOf(CompletionInput::class, $input); + + $completions = $this->engine->complete($metadata, $input); + + $this->assertSame( + [ + ['value' => 'about', 'display' => 'about About command'], + ['value' => 'cache:clear', 'display' => null], + ], + $this->mapCompletions($completions), + ); + } + + #[Test] + public function skips_used_non_repeatable_flags_but_keeps_repeatable_flags(): void + { + $metadata = $this->parseMetadata([ + 'commands' => [ + 'deploy' => [ + 'hidden' => false, + 'description' => null, + 'flags' => [ + [ + 'name' => 'force', + 'flag' => '--force', + 'aliases' => ['-f'], + 'description' => 'Force mode', + 'value_options' => [], + 'repeatable' => false, + ], + [ + 'name' => 'tag', + 'flag' => '--tag=', + 'aliases' => ['-t'], + 'description' => 'Tag value', + 'value_options' => ['alpha', 'beta'], + 'repeatable' => true, + ], + [ + 'name' => 'verbose', + 'flag' => '--verbose', + 'aliases' => ['-v'], + 'description' => 'Verbose output', + 'value_options' => [], + 'repeatable' => false, + ], + ], + ], + ], + ]); + + $input = $this->inputNormalizer->normalize(['tempest', 'deploy', '--verbose', ''], 3); + + $this->assertInstanceOf(CompletionInput::class, $input); + + $completions = $this->engine->complete($metadata, $input); + $mappedCompletions = $this->mapCompletions($completions); + + $this->assertSame(['--force', '--tag='], array_column($mappedCompletions, 'value')); + $this->assertStringContainsString('--tag= / -t', $mappedCompletions[1]['display']); + $this->assertStringContainsString('Tag value', $mappedCompletions[1]['display']); + } + + #[Test] + public function treats_combined_short_flags_as_used(): void + { + $metadata = $this->parseMetadata([ + 'commands' => [ + 'deploy' => [ + 'hidden' => false, + 'description' => null, + 'flags' => [ + [ + 'name' => 'ansi', + 'flag' => '--ansi', + 'aliases' => ['-a'], + 'description' => null, + 'value_options' => [], + 'repeatable' => false, + ], + [ + 'name' => 'force', + 'flag' => '--force', + 'aliases' => ['-f'], + 'description' => null, + 'value_options' => [], + 'repeatable' => false, + ], + ], + ], + ], + ]); + + $input = $this->inputNormalizer->normalize(['tempest', 'deploy', '-af', ''], 3); + + $this->assertInstanceOf(CompletionInput::class, $input); + + $completions = $this->engine->complete($metadata, $input); + + $this->assertSame([], $this->mapCompletions($completions)); + } + + #[Test] + public function handles_php_passthrough_command_normalization(): void + { + $normalized = $this->inputNormalizer->normalize(['/usr/bin/php', 'vendor/bin/tempest', 'cache:clear'], 2); + + $this->assertInstanceOf(CompletionInput::class, $normalized); + + $this->assertSame( + ['vendor/bin/tempest', 'cache:clear'], + $normalized->words, + ); + + $this->assertSame(1, $normalized->currentIndex); + } + + #[Test] + public function ignores_no_prefixed_long_flags_when_marking_used_flags(): void + { + $metadata = $this->parseMetadata([ + 'commands' => [ + 'deploy' => [ + 'hidden' => false, + 'description' => null, + 'flags' => [ + [ + 'name' => 'ansi', + 'flag' => '--ansi', + 'aliases' => ['-a'], + 'description' => null, + 'value_options' => [], + 'repeatable' => false, + ], + ], + ], + ], + ]); + + $input = $this->inputNormalizer->normalize(['tempest', 'deploy', '--no-ansi', ''], 3); + + $this->assertInstanceOf(CompletionInput::class, $input); + + $completions = $this->engine->complete($metadata, $input); + + $this->assertSame([], $this->mapCompletions($completions)); + } + + private function parseMetadata(array $metadata): CompletionMetadata + { + $parsedMetadata = $this->metadataParser->parse($metadata); + + $this->assertInstanceOf(CompletionMetadata::class, $parsedMetadata); + + return $parsedMetadata; + } + + private function mapCompletions(array $completions): array + { + return array_map( + fn (CompletionCandidate $completion): array => [ + 'value' => $completion->value, + 'display' => $completion->display, + ], + $completions, + ); + } +} diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php new file mode 100644 index 0000000000..2281f179f4 --- /dev/null +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -0,0 +1,81 @@ +markTestSkipped('Shell completion is not supported on Windows.'); + } + + $this->completionRuntime = new CompletionRuntime(); + } + + #[Test] + #[DataProvider('supportedPlatformDataProvider')] + public function isSupportedPlatform(string $osFamily, bool $expected): void + { + $this->assertSame($expected, $this->completionRuntime->isSupportedPlatform($osFamily)); + } + + public static function supportedPlatformDataProvider(): array + { + return [ + 'linux' => ['Linux', true], + 'darwin' => ['Darwin', true], + 'windows' => ['Windows', false], + ]; + } + + #[Test] + public function getUnsupportedPlatformMessage(): void + { + $this->assertStringContainsString('Windows', $this->completionRuntime->getUnsupportedPlatformMessage()); + } + + #[Test] + public function getInstallationDirectory(): void + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + $this->assertSame($home . '/.tempest/completion', $this->completionRuntime->getInstallationDirectory()); + } + + #[Test] + public function getInstalledCompletionPath(): void + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + $this->assertSame($home . '/.tempest/completion/tempest.zsh', $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH)); + $this->assertSame($home . '/.tempest/completion/tempest.bash', $this->completionRuntime->getInstalledCompletionPath(Shell::BASH)); + } + + #[Test] + public function getPostInstallInstructions(): void + { + $zshInstructions = $this->completionRuntime->getPostInstallInstructions(Shell::ZSH); + $this->assertNotEmpty($zshInstructions); + $this->assertStringContainsStringIgnoringCase('source', implode("\n", $zshInstructions)); + + $bashInstructions = $this->completionRuntime->getPostInstallInstructions(Shell::BASH); + $this->assertNotEmpty($bashInstructions); + $this->assertStringContainsStringIgnoringCase('source', implode("\n", $bashInstructions)); + } +} diff --git a/packages/console/tests/Enums/ShellTest.php b/packages/console/tests/Enums/ShellTest.php index c2f3b87f90..b79965b14c 100644 --- a/packages/console/tests/Enums/ShellTest.php +++ b/packages/console/tests/Enums/ShellTest.php @@ -14,6 +14,15 @@ */ final class ShellTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + } + #[Test] #[DataProvider('detectDataProvider')] public function detect(string|false $shellEnv, ?Shell $expected): void @@ -51,31 +60,13 @@ public static function detectDataProvider(): array ]; } - #[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.zsh', 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 { @@ -91,16 +82,4 @@ public function getRcFile(): void $this->assertSame($home . '/.zshrc', Shell::ZSH->getRcFile()); $this->assertSame($home . '/.bashrc', Shell::BASH->getRcFile()); } - - #[Test] - public function getPostInstallInstructions(): void - { - $zshInstructions = Shell::ZSH->getPostInstallInstructions(); - $this->assertNotEmpty($zshInstructions); - $this->assertStringContainsString('fpath', $zshInstructions[0]); - - $bashInstructions = Shell::BASH->getPostInstallInstructions(); - $this->assertNotEmpty($bashInstructions); - $this->assertStringContainsStringIgnoringCase('source', $bashInstructions[0]); - } } diff --git a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php new file mode 100644 index 0000000000..dd86cc5950 --- /dev/null +++ b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php @@ -0,0 +1,80 @@ +markTestSkipped('Shell completion is not supported on Windows.'); + } + + $this->completionRuntime = new CompletionRuntime(); + } + + protected function tearDown(): void + { + if ($this->generatedPath !== null && Filesystem\is_file($this->generatedPath)) { + Filesystem\delete_file($this->generatedPath); + $this->generatedPath = null; + } + + parent::tearDown(); + } + + #[Test] + public function generate_writes_completion_metadata_to_default_path(): void + { + $this->generatedPath = $this->completionRuntime->getMetadataPath(); + + if (Filesystem\is_file($this->generatedPath)) { + Filesystem\delete_file($this->generatedPath); + } + + $this->console + ->call('completion:generate') + ->assertSee('Wrote completion metadata to:') + ->assertSee('commands.json') + ->assertSuccess(); + + $this->assertTrue(Filesystem\is_file($this->generatedPath)); + + $metadata = Filesystem\read_json($this->generatedPath); + $flags = array_column($metadata['commands']['completion:test']['flags'], 'flag'); + $installFlags = array_column($metadata['commands']['completion:install']['flags'], null, 'name'); + + $this->assertSame(['--flag', '--items=', '--value='], $flags); + $this->assertSame('Install shell completion for Tempest', $metadata['commands']['completion:install']['description']); + $this->assertSame(['-s'], $installFlags['shell']['aliases']); + $this->assertSame('The shell to install completions for (zsh, bash)', $installFlags['shell']['description']); + $this->assertSame(['bash', 'zsh'], $installFlags['shell']['value_options']); + } + + #[Test] + public function generate_writes_completion_metadata_to_custom_path(): void + { + $this->generatedPath = $this->internalStorage . '/completion/custom-commands.json'; + + $this->console + ->call("completion:generate --path={$this->generatedPath}") + ->assertSee('Wrote completion metadata to:') + ->assertSee('custom-commands.json') + ->assertSuccess(); + + $this->assertTrue(Filesystem\is_file($this->generatedPath)); + } +} diff --git a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php index ac5d80ccf0..f6439ac31f 100644 --- a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Integration\Console\Commands; use PHPUnit\Framework\Attributes\Test; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Enums\Shell; use Tempest\Support\Filesystem; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -16,6 +17,33 @@ final class CompletionInstallCommandTest extends FrameworkIntegrationTestCase { private ?string $installedFile = null; + private ?string $metadataFile = null; + + private string $profileDirectory; + + private ?string $originalHome = null; + + private CompletionRuntime $completionRuntime; + + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + + $this->completionRuntime = new CompletionRuntime(); + + $this->originalHome = getenv('HOME') ?: null; + $this->profileDirectory = $this->internalStorage . '/profile'; + + Filesystem\ensure_directory_exists($this->profileDirectory); + putenv("HOME={$this->profileDirectory}"); + $_ENV['HOME'] = $this->profileDirectory; + $_SERVER['HOME'] = $this->profileDirectory; + } + protected function tearDown(): void { if ($this->installedFile !== null && Filesystem\is_file($this->installedFile)) { @@ -23,19 +51,39 @@ protected function tearDown(): void $this->installedFile = null; } + if ($this->metadataFile !== null && Filesystem\is_file($this->metadataFile)) { + Filesystem\delete_file($this->metadataFile); + $this->metadataFile = null; + } + + if ($this->originalHome === null) { + putenv('HOME'); + unset($_ENV['HOME'], $_SERVER['HOME']); + } else { + putenv("HOME={$this->originalHome}"); + $_ENV['HOME'] = $this->originalHome; + $_SERVER['HOME'] = $this->originalHome; + } + parent::tearDown(); } #[Test] public function install_with_explicit_shell_flag(): void { - $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + $this->prepareCompletionRuntime(); + + $this->installedFile = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); $this->console ->call('completion:install --shell=zsh --force') ->assertSee('Installed completion script to:') - ->assertSee('_tempest') ->assertSuccess(); + + $installedScript = Filesystem\read_file($this->installedFile); + + $this->assertStringContainsString('/vendor/bin/tempest-complete', $installedScript); + $this->assertStringContainsString('/.tempest/completion/commands.json', $installedScript); } #[Test] @@ -51,30 +99,36 @@ public function install_with_invalid_shell(): void #[Test] public function install_shows_post_install_instructions_for_zsh(): void { - $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + $this->prepareCompletionRuntime(); + + $this->installedFile = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); $this->console ->call('completion:install --shell=zsh --force') - ->assertSee('fpath=') - ->assertSee('compinit') + ->assertSee('source') + ->assertSee('.zshrc') ->assertSuccess(); } #[Test] public function install_shows_post_install_instructions_for_bash(): void { - $this->installedFile = Shell::BASH->getInstalledCompletionPath(); + $this->prepareCompletionRuntime(); + + $this->installedFile = $this->completionRuntime->getInstalledCompletionPath(Shell::BASH); $this->console ->call('completion:install --shell=bash --force') ->assertSee('source') - ->assertSee('tempest.bash') + ->assertSee('.bashrc') ->assertSuccess(); } #[Test] public function install_cancelled_when_user_denies_confirmation(): void { + $this->prepareCompletionRuntime(); + $this->console ->call('completion:install --shell=zsh') ->assertSee('Installing zsh completions') @@ -84,28 +138,34 @@ public function install_cancelled_when_user_denies_confirmation(): void } #[Test] - public function install_creates_directory_if_not_exists(): void + public function install_asks_for_overwrite_when_file_exists(): void { - $targetDir = Shell::ZSH->getCompletionsDirectory(); - $dirExisted = Filesystem\is_directory($targetDir); + $this->prepareCompletionRuntime(); - $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); - $result = $this->console - ->call('completion:install --shell=zsh --force'); + Filesystem\create_directory($targetDir); + Filesystem\write_file($targetPath, '# existing content'); - if (! $dirExisted) { - $result->assertSee('Created directory:'); - } + $this->installedFile = $targetPath; - $result->assertSuccess(); + $this->console + ->call('completion:install --shell=zsh') + ->confirm() + ->assertSee('Completion file already exists') + ->deny() + ->assertSee('Installation cancelled') + ->assertCancelled(); } #[Test] - public function install_asks_for_overwrite_when_file_exists(): void + public function install_overwrites_existing_file_when_user_accepts_overwrite_default(): void { - $targetPath = Shell::ZSH->getInstalledCompletionPath(); - $targetDir = Shell::ZSH->getCompletionsDirectory(); + $this->prepareCompletionRuntime(); + + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# existing content'); @@ -116,8 +176,15 @@ public function install_asks_for_overwrite_when_file_exists(): void ->call('completion:install --shell=zsh') ->confirm() ->assertSee('Completion file already exists') - ->deny() - ->assertSee('Installation cancelled') - ->assertCancelled(); + ->submit() + ->assertSee('Installed completion script to:') + ->assertSuccess(); + } + + private function prepareCompletionRuntime(): void + { + $this->metadataFile = $this->completionRuntime->getMetadataPath(); + Filesystem\ensure_directory_exists(dirname($this->metadataFile)); + Filesystem\write_json($this->metadataFile, ['version' => 1, 'commands' => []]); } } diff --git a/tests/Integration/Console/Commands/CompletionShowCommandTest.php b/tests/Integration/Console/Commands/CompletionShowCommandTest.php index d4c1b32510..2afc0aa381 100644 --- a/tests/Integration/Console/Commands/CompletionShowCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionShowCommandTest.php @@ -12,12 +12,24 @@ */ final class CompletionShowCommandTest extends FrameworkIntegrationTestCase { + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + } + #[Test] public function show_zsh_completion_script(): void { $this->console ->call('completion:show --shell=zsh') - ->assertSee('_tempest') + ->assertSee('tempest-complete') + ->assertSee('commands.json') + ->assertSee('_tempest_php_original_compdef') + ->assertSee('_compskip=all') ->assertSuccess(); } @@ -26,7 +38,13 @@ public function show_bash_completion_script(): void { $this->console ->call('completion:show --shell=bash') - ->assertSee('_tempest') + ->assertSee('tempest-complete') + ->assertSee('commands.json') + ->assertSee('if [[ "$current_word" == *[=:]* ]] && [[ "$current_segment" != "$current_word" ]]; then') + ->assertSee('if [[ -z "$current_segment" || "$current_segment" == "=" ]]; then') + ->assertSee('__tempest_php_original_completion') + ->assertSee('complete -o bashdefault -o default -F _tempest tempest') + ->assertSee('compopt +o default +o bashdefault') ->assertSuccess(); } diff --git a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php index 4f042f4583..7983d379b0 100644 --- a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Integration\Console\Commands; use PHPUnit\Framework\Attributes\Test; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Enums\Shell; use Tempest\Support\Filesystem; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -14,11 +15,50 @@ */ final class CompletionUninstallCommandTest extends FrameworkIntegrationTestCase { + private string $profileDirectory; + + private ?string $originalHome = null; + + private CompletionRuntime $completionRuntime; + + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + + $this->completionRuntime = new CompletionRuntime(); + + $this->originalHome = getenv('HOME') ?: null; + $this->profileDirectory = $this->internalStorage . '/profile'; + + Filesystem\ensure_directory_exists($this->profileDirectory); + putenv("HOME={$this->profileDirectory}"); + $_ENV['HOME'] = $this->profileDirectory; + $_SERVER['HOME'] = $this->profileDirectory; + } + + protected function tearDown(): void + { + if ($this->originalHome === null) { + putenv('HOME'); + unset($_ENV['HOME'], $_SERVER['HOME']); + } else { + putenv("HOME={$this->originalHome}"); + $_ENV['HOME'] = $this->originalHome; + $_SERVER['HOME'] = $this->originalHome; + } + + parent::tearDown(); + } + #[Test] public function uninstall_with_explicit_shell_flag(): void { - $targetPath = Shell::ZSH->getInstalledCompletionPath(); - $targetDir = Shell::ZSH->getCompletionsDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# completion script'); @@ -26,7 +66,7 @@ public function uninstall_with_explicit_shell_flag(): void $this->console ->call('completion:uninstall --shell=zsh --force') ->assertSee('Removed completion script:') - ->assertSee('_tempest') + ->assertSee('tempest.zsh') ->assertSuccess(); $this->assertFalse(Filesystem\is_file($targetPath)); @@ -45,7 +85,7 @@ public function uninstall_with_invalid_shell(): void #[Test] public function uninstall_when_file_not_exists(): void { - $targetPath = Shell::ZSH->getInstalledCompletionPath(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); if (Filesystem\is_file($targetPath)) { Filesystem\delete_file($targetPath); @@ -62,8 +102,8 @@ public function uninstall_when_file_not_exists(): void #[Test] public function uninstall_shows_config_file_reminder(): void { - $targetPath = Shell::BASH->getInstalledCompletionPath(); - $targetDir = Shell::BASH->getCompletionsDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::BASH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# completion script'); @@ -78,8 +118,8 @@ public function uninstall_shows_config_file_reminder(): void #[Test] public function uninstall_cancelled_when_user_denies_confirmation(): void { - $targetPath = Shell::ZSH->getInstalledCompletionPath(); - $targetDir = Shell::ZSH->getCompletionsDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# completion script');