Skip to content

Commit 772eb6e

Browse files
authored
refactor(console): improve command completion (#1967)
1 parent d5665fe commit 772eb6e

37 files changed

Lines changed: 2017 additions & 253 deletions

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
/packages/clock/ @aidan-casey
2121
/packages/command-bus/ @brendt @aidan-casey
2222
/packages/console/ @brendt
23+
/packages/console/src/Completion @xHeaven
2324
/packages/container/ @brendt
2425
/packages/core/ @brendt
2526
/packages/cryptography/ @innocenzi

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@
237237
}
238238
},
239239
"bin": [
240-
"packages/console/bin/tempest"
240+
"packages/console/bin/tempest",
241+
"packages/console/bin/tempest-complete"
241242
],
242243
"config": {
243244
"allow-plugins": {

docs/1-essentials/04-console-commands.md

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,9 @@ Interactive components are only supported on Mac and Linux. On Windows, Tempest
240240

241241
## Shell completion
242242

243-
Tempest provides shell completion for Zsh and Bash. This allows you to press `Tab` to autocomplete command names and options.
243+
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.
244+
245+
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.
244246

245247
### Installing completions
246248

@@ -250,28 +252,38 @@ Run the install command and follow the prompts:
250252
<dim>./</dim>tempest completion:install
251253
```
252254

253-
The installer will detect your current shell, copy the completion script to the appropriate location, and provide instructions for enabling it.
254-
255-
For Zsh, you'll need to ensure the completions directory is in your `fpath` and reload completions:
255+
This will:
256256

257-
```zsh
258-
# Add to ~/.zshrc
259-
fpath=(~/.zsh/completions $fpath)
260-
autoload -Uz compinit && compinit
261-
```
257+
1. Detect your shell (or use `--shell=zsh` / `--shell=bash`).
258+
2. Generate completion metadata (`commands.json`) for all registered commands.
259+
3. Install the completion script to the appropriate location.
262260

263-
For Bash, source the completion file in your `~/.bashrc`:
261+
After installation, add the following line to your shell configuration file and restart your terminal:
264262

265263
```bash
266-
source ~/.bash_completion.d/tempest.bash
264+
# Zsh: add to ~/.zshrc
265+
source ~/.tempest/completion/tempest.zsh
266+
267+
# Bash: add to ~/.bashrc
268+
source ~/.tempest/completion/tempest.bash
267269
```
268270

269-
### Additional commands
271+
### Keeping completions up to date
272+
273+
After adding or removing commands, regenerate the metadata:
274+
275+
```console
276+
<dim>./</dim>tempest completion:generate
277+
```
270278

271-
You may also use these related commands:
279+
### Available commands
272280

273-
- `completion:show` — Output the completion script to stdout (useful for custom installation)
274-
- `completion:uninstall` — Remove the installed completion script
281+
| Command | Description |
282+
|------------------------|--------------------------------------------------------------------------|
283+
| `completion:install` | Install the completion script and generate metadata. |
284+
| `completion:generate` | Regenerate the completion metadata JSON. |
285+
| `completion:show` | Output the completion script to stdout (useful for custom installation). |
286+
| `completion:uninstall` | Remove the installed completion script. |
275287

276288
## Middleware
277289

packages/console/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
console.log
22
debug.log
3-
tempest.log
3+
tempest.log
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
declare(strict_types=1);
5+
6+
use Tempest\Console\Completion\CompletionApplication;
7+
8+
$metadataPath = $_SERVER['argv'][1] ?? null;
9+
10+
if (! $metadataPath) {
11+
return;
12+
}
13+
14+
require_once dirname($metadataPath, 3) . '/vendor/autoload.php';
15+
16+
if (! class_exists(CompletionApplication::class)) {
17+
return;
18+
}
19+
20+
if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') {
21+
return;
22+
}
23+
24+
$script = $_SERVER['SCRIPT_FILENAME'] ?? null;
25+
26+
if (! is_string($script) || realpath($script) !== __FILE__) {
27+
return;
28+
}
29+
30+
new CompletionApplication()->run(array_slice($_SERVER['argv'] ?? [], 1));

packages/console/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
}
3232
},
3333
"bin": [
34-
"bin/tempest"
34+
"bin/tempest",
35+
"bin/tempest-complete"
3536
]
3637
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Actions;
6+
7+
use BackedEnum;
8+
use Tempest\Console\ConsoleConfig;
9+
use Tempest\Console\Input\ConsoleArgumentDefinition;
10+
11+
use function Tempest\Support\str;
12+
13+
final readonly class BuildCompletionMetadata
14+
{
15+
public function __construct(
16+
private ConsoleConfig $consoleConfig,
17+
) {}
18+
19+
public function __invoke(): array
20+
{
21+
$commands = [];
22+
23+
foreach ($this->consoleConfig->commands as $name => $command) {
24+
$flags = array_map(
25+
fn (ConsoleArgumentDefinition $definition): array => [
26+
'name' => $definition->name,
27+
'flag' => $this->buildFlagNotation($definition),
28+
'aliases' => $this->buildFlagAliases($definition),
29+
'description' => $definition->description,
30+
'value_options' => $this->buildValueOptions($definition),
31+
'repeatable' => $definition->type === 'array' || $definition->isVariadic,
32+
'requires_value' => $definition->type !== 'bool',
33+
],
34+
$command->getArgumentDefinitions(),
35+
);
36+
37+
usort($flags, static fn (array $a, array $b): int => $a['flag'] <=> $b['flag']);
38+
39+
$commands[$name] = [
40+
'hidden' => $command->hidden,
41+
'description' => $command->description,
42+
'flags' => $flags,
43+
];
44+
}
45+
46+
ksort($commands);
47+
48+
return [
49+
'version' => 1,
50+
'commands' => $commands,
51+
];
52+
}
53+
54+
private function buildFlagNotation(ConsoleArgumentDefinition $definition): string
55+
{
56+
$flag = "--{$definition->name}";
57+
58+
if ($definition->type !== 'bool') {
59+
$flag .= '=';
60+
}
61+
62+
return $flag;
63+
}
64+
65+
private function buildFlagAliases(ConsoleArgumentDefinition $definition): array
66+
{
67+
$aliases = array_values(array_filter(array_map(static function (string $alias): ?string {
68+
$normalized = ltrim(str($alias)->trim()->kebab()->toString(), '-');
69+
70+
if ($normalized === '') {
71+
return null;
72+
}
73+
74+
return match (strlen($normalized)) {
75+
1 => "-{$normalized}",
76+
default => "--{$normalized}",
77+
};
78+
}, $definition->aliases)));
79+
80+
sort($aliases);
81+
82+
return $aliases;
83+
}
84+
85+
private function buildValueOptions(ConsoleArgumentDefinition $definition): array
86+
{
87+
if (! $definition->isBackedEnum()) {
88+
return [];
89+
}
90+
91+
/** @var class-string<BackedEnum> $type */
92+
$type = $definition->type;
93+
94+
$options = array_map(
95+
static fn (BackedEnum $case): string => (string) $case->value,
96+
$type::cases(),
97+
);
98+
99+
sort($options);
100+
101+
return $options;
102+
}
103+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Commands;
6+
7+
use Tempest\Console\Actions\BuildCompletionMetadata;
8+
use Tempest\Console\CompletionRuntime;
9+
use Tempest\Console\Console;
10+
use Tempest\Console\ConsoleArgument;
11+
use Tempest\Console\ConsoleCommand;
12+
use Tempest\Console\ExitCode;
13+
use Tempest\Support\Filesystem;
14+
15+
final readonly class CompletionGenerateCommand
16+
{
17+
public function __construct(
18+
private Console $console,
19+
private CompletionRuntime $completionRuntime,
20+
private BuildCompletionMetadata $buildCompletionMetadata,
21+
) {}
22+
23+
#[ConsoleCommand(
24+
name: 'completion:generate',
25+
description: 'Generate shell completion metadata as JSON',
26+
)]
27+
public function __invoke(
28+
#[ConsoleArgument(
29+
description: 'Optional output path for the completion metadata JSON',
30+
aliases: ['-p'],
31+
)]
32+
?string $path = null,
33+
): ExitCode {
34+
if (! $this->completionRuntime->isSupportedPlatform()) {
35+
$this->console->error($this->completionRuntime->getUnsupportedPlatformMessage());
36+
37+
return ExitCode::ERROR;
38+
}
39+
40+
$path ??= $this->completionRuntime->getMetadataPath();
41+
42+
Filesystem\write_json($path, ($this->buildCompletionMetadata)(), pretty: false);
43+
44+
$this->console->success("Wrote completion metadata to: {$path}");
45+
46+
return ExitCode::SUCCESS;
47+
}
48+
}

packages/console/src/Commands/CompletionInstallCommand.php

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55
namespace Tempest\Console\Commands;
66

77
use Symfony\Component\Filesystem\Path;
8+
use Tempest\Console\Actions\BuildCompletionMetadata;
89
use Tempest\Console\Actions\ResolveShell;
10+
use Tempest\Console\CompletionRuntime;
911
use Tempest\Console\Console;
1012
use Tempest\Console\ConsoleArgument;
1113
use Tempest\Console\ConsoleCommand;
1214
use Tempest\Console\Enums\Shell;
1315
use Tempest\Console\ExitCode;
16+
use Tempest\Console\Middleware\ForceMiddleware;
1417
use Tempest\Support\Filesystem;
1518

1619
use function Tempest\Support\path;
@@ -19,25 +22,29 @@
1922
{
2023
public function __construct(
2124
private Console $console,
25+
private CompletionRuntime $completionRuntime,
2226
private ResolveShell $resolveShell,
27+
private BuildCompletionMetadata $buildCompletionMetadata,
2328
) {}
2429

2530
#[ConsoleCommand(
2631
name: 'completion:install',
2732
description: 'Install shell completion for Tempest',
33+
middleware: [ForceMiddleware::class],
2834
)]
2935
public function __invoke(
3036
#[ConsoleArgument(
3137
description: 'The shell to install completions for (zsh, bash)',
3238
aliases: ['-s'],
3339
)]
3440
?Shell $shell = null,
35-
#[ConsoleArgument(
36-
description: 'Skip confirmation prompts',
37-
aliases: ['-f'],
38-
)]
39-
bool $force = false,
4041
): ExitCode {
42+
if (! $this->completionRuntime->isSupportedPlatform()) {
43+
$this->console->error($this->completionRuntime->getUnsupportedPlatformMessage());
44+
45+
return ExitCode::ERROR;
46+
}
47+
4148
$shell ??= ($this->resolveShell)('Which shell do you want to install completions for?');
4249

4350
if ($shell === null) {
@@ -47,16 +54,16 @@ public function __invoke(
4754
}
4855

4956
$sourcePath = $this->getSourcePath($shell);
50-
$targetDir = $shell->getCompletionsDirectory();
51-
$targetPath = $shell->getInstalledCompletionPath();
57+
$targetDir = $this->completionRuntime->getInstallationDirectory();
58+
$targetPath = $this->completionRuntime->getInstalledCompletionPath($shell);
5259

5360
if (! Filesystem\is_file($sourcePath)) {
5461
$this->console->error("Completion script not found: {$sourcePath}");
5562

5663
return ExitCode::ERROR;
5764
}
5865

59-
if (! $force) {
66+
if (! $this->console->isForced) {
6067
$this->console->info("Installing {$shell->value} completions");
6168
$this->console->keyValue('Source', $sourcePath);
6269
$this->console->keyValue('Target', $targetPath);
@@ -69,22 +76,24 @@ public function __invoke(
6976
}
7077
}
7178

79+
Filesystem\write_json($this->completionRuntime->getMetadataPath(), ($this->buildCompletionMetadata)(), pretty: false);
80+
7281
Filesystem\ensure_directory_exists($targetDir);
7382

74-
if (Filesystem\is_file($targetPath)) {
75-
if (! $force && ! $this->console->confirm('Completion file already exists. Overwrite?', default: false)) {
76-
$this->console->warning('Installation cancelled.');
83+
if (Filesystem\is_file($targetPath) && ! $this->console->confirm('Completion file already exists. Overwrite?', default: true)) {
84+
$this->console->warning('Installation cancelled.');
7785

78-
return ExitCode::CANCELLED;
79-
}
86+
return ExitCode::CANCELLED;
8087
}
8188

82-
Filesystem\copy_file($sourcePath, $targetPath, overwrite: true);
89+
$script = Filesystem\read_file($sourcePath);
90+
Filesystem\write_file($targetPath, $script);
91+
8392
$this->console->success("Installed completion script to: {$targetPath}");
8493

8594
$this->console->writeln();
8695
$this->console->info('Next steps:');
87-
$this->console->instructions($shell->getPostInstallInstructions());
96+
$this->console->instructions($this->completionRuntime->getPostInstallInstructions($shell));
8897

8998
return ExitCode::SUCCESS;
9099
}

0 commit comments

Comments
 (0)