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');