Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,54 @@ Typen gleichnamiger Scope-Optionen führen bei der Registrierung zu einem
Fehler. `CliParameter` benennt nur die CLI-Option um; die Bindung an den
PHP-Parameter erfolgt weiterhin über dessen PHP-Namen.

## Executable-Name und impliziter Scope

`CliApplication::run($argv)` bestimmt den Programmnamen aus dem Basename
von `$argv[0]`. Ein registrierter `CliScope` mit genau diesem Namen ist
automatisch die Programmebene; seine Actions sind ohne Wiederholung des
Programnamens erreichbar. Andere Scopes bleiben eigenständige Gruppen.

Beispielsweise kann das Executable `tool` die folgenden Klassen registrieren:

```php
#[CliScope('tool')]
class GeneralCommands
{
public function init(): void
{
echo 'Initialized';
}
}

#[CliScope('edit')]
class EditCommands
{
public function create(): void
{
echo 'Created';
}

public function resolve(): void
{
echo 'Resolved';
}
}
```

```php
$app = new CliApplication();
$app->addClass(GeneralCommands::class);
$app->addClass(EditCommands::class);
$app->run($argv);
```

Damit sind `tool init`, `tool edit create` und `tool edit resolve`
möglich, ohne den Programmnamen künstlich in `$argv` einzufügen. Die
Hilfe unter `tool -h` zeigt die Actions des impliziten Scopes direkt.
Aufrufe mit explizitem Scope wie `tool tool init` bleiben möglich.
Ein Executable ohne namensgleichen Scope verwendet weiterhin die
bisherigen expliziten Gruppen.

## Sub-Commands und Scope-Optionen

Ein `CliScope` kann als Pfad angegeben werden. Dadurch lassen sich beliebig
Expand Down
92 changes: 92 additions & 0 deletions spec/CliApplicationSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,29 @@ public function run(): void
}
}

#[CliScope('tool')]
class ExecutableCommands
{
public function init(): void
{
echo 'initialized';
}
}

#[CliScope('edit')]
class ExecutableEditCommands
{
public function create(): void
{
echo 'created';
}

public function resolve(): void
{
echo 'resolved';
}
}

class CliApplicationSpec extends ObjectBehavior
{
public function it_combines_actions_from_classes_in_the_same_scope(): void
Expand Down Expand Up @@ -217,6 +240,75 @@ public function it_shows_nested_subcommands_and_parent_options_in_help(): void
}
}

public function it_treats_the_executable_name_as_an_implicit_scope(): void
{
$this->addClass(ExecutableCommands::class);
$this->addClass(ExecutableEditCommands::class);

ob_start();
$this->run(['/usr/local/bin/tool', 'init']);
$output = ob_get_clean();
if (trim($output) !== 'initialized') {
throw new \RuntimeException('Implicit executable scope did not dispatch init.');
}

ob_start();
$this->run(['/usr/local/bin/tool', 'edit', 'create']);
$output = ob_get_clean();
if (trim($output) !== 'created') {
throw new \RuntimeException('Independent edit scope did not dispatch create.');
}

ob_start();
$this->run(['tool', 'edit', 'resolve']);
$output = ob_get_clean();
if (trim($output) !== 'resolved') {
throw new \RuntimeException('Independent edit scope did not dispatch resolve.');
}

// The previous explicit scope form stays supported.
ob_start();
$this->run(['tool', 'tool', 'init']);
$output = ob_get_clean();
if (trim($output) !== 'initialized') {
throw new \RuntimeException('Explicit legacy scope must still work.');
}
}

public function it_flattens_only_the_executable_scope_in_help(): void
{
$this->addClass(ExecutableCommands::class);
$this->addClass(ExecutableEditCommands::class);

ob_start();
$this->run(['/usr/local/bin/tool', '-h']);
$help = ob_get_clean();
if (! str_contains($help, "\ntool [command]") || ! str_contains($help, 'init')
|| ! str_contains($help, 'edit [COMMAND]')
|| str_contains($help, "\n tool [COMMAND]")) {
throw new \RuntimeException('Executable scope must be implicit in help.');
}

ob_start();
$this->run(['tool', 'edit', '-h']);
$help = ob_get_clean();
if (! str_contains($help, 'create') || ! str_contains($help, 'resolve')) {
throw new \RuntimeException('Edit subcommands must be discoverable in help.');
}
}

public function it_preserves_regular_explicit_scopes_without_a_matching_executable_scope(): void
{
$this->addClass(CreateAction::class);

ob_start();
$this->run(['tool', 'project', 'inspect', 'example']);
$output = ob_get_clean();
if (trim($output) !== 'example') {
throw new \RuntimeException('Other executable names must retain explicit scopes.');
}
}

public function it_keeps_command_descriptions_optional(): void
{
$command = new CliCommand('plain');
Expand Down
7 changes: 4 additions & 3 deletions src/CliApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ public function addClass(string $className): void
/**
* Executes argv and writes command or help output directly to the current output stream.
*
* The first element is the executable name. Exceptions propagate to the
* caller. The method deliberately does not start or manipulate PHP output
* The first element is the executable path or name. A registered scope
* matching its basename is implicitly available at the command root.
* Exceptions propagate to the caller. The method deliberately does not start or manipulate PHP output
* buffering, so embedding applications retain full control over output.
*
* Example: $app->run(['tool', 'project', 'create', '--name', 'demo']);
Expand All @@ -65,7 +66,7 @@ public function addClass(string $className): void
public function run(array $argv): void
{
$name = array_shift($argv);
$this->commandSet->setName($name ?? '');
$this->commandSet->setName(basename($name ?? ''));

$presetContainer = new CliPreset();
$presetFile = getcwd() . '/cli_presets.ini';
Expand Down
37 changes: 35 additions & 2 deletions src/Types/T_CommandSet.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,15 @@ public function getHelp(
$sig .= "\n" . $this->desc . "\n";
}

// Der Scope des Executables wird in der Hilfe als Programmebene
// angezeigt; Untergruppen wie edit bleiben explizit sichtbar.
$visibleCommands = $this->commandsForExecutable();
$nameWidth = 0;
foreach ($this->commands as $command) {
foreach ($visibleCommands as $command) {
$suffix = $command instanceof T_CommandGroup ? " [COMMAND]" : "";
$nameWidth = max($nameWidth, strlen($command->name . $suffix));
}
foreach ($this->commands as $command) {
foreach ($visibleCommands as $command) {
$sig .= $command->getHelp($detailed, $nameWidth, false, 2);
}
foreach ($this->cliPresets as $preset) {
Expand All @@ -77,6 +80,26 @@ public function getHelp(
return $sig;
}

/**
* Shows commands of a scope named like the executable at the root level.
*
* Other scopes remain unaffected; the original registry is not mutated.
*
* @return T_Command[]
*/
private function commandsForExecutable(): array
{
$commands = [];
foreach ($this->commands as $command) {
if ($command instanceof T_CommandGroup && $command->name === $this->name) {
array_push($commands, ...$command->commands);
continue;
}
$commands[] = $command;
}
return $commands;
}

public function setName($name)
{
$this->name = $name;
Expand All @@ -94,12 +117,22 @@ public function dispatch(array $argv, array &$arguments = [], $object = null): v
echo $this->getHelp(false);
return;
}
// Explizite Top-Level-Scopes behalten Vorrang.
foreach ($this->commands as $cmd) {
if ($cmd->name === $command) {
$cmd->dispatch($argv, $arguments, $object);
return;
}
}

// Ein Scope mit dem Namen des Executables ist implizit aktiv.
// Dadurch braucht der Launcher keine doppelten argv-Tokens.
foreach ($this->commands as $cmd) {
if ($cmd instanceof T_CommandGroup && $cmd->name === $this->name) {
$cmd->dispatch([$command, ...$argv], $arguments, $object);
return;
}
}
throw new CliException("Command '$command' not found.");
}
}
Loading