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
33 changes: 33 additions & 0 deletions src/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ abstract class Command
* Tells if command is active.
*/
protected bool $active = true;
/**
* The exit code the command wants the process to use.
*/
protected int $exitCode = 0;

/**
* Command constructor.
Expand Down Expand Up @@ -449,6 +453,35 @@ protected function definitionLabel(int | string $key, array $definition) : strin
return (string) $key;
}

/**
* Get the exit code the command wants the process to use.
*
* Returns 0 unless the command called setExitCode() during run().
*
* @return int
*/
#[Pure]
public function getExitCode() : int
{
return $this->exitCode;
}

/**
* Set the exit code the process should use after this command runs.
*
* Lets a command fail, or succeed with a specific code, without writing
* to STDERR or terminating the process itself.
*
* @param int $code The exit code, 0 means success
*
* @return static
*/
public function setExitCode(int $code) : static
{
$this->exitCode = $code;
return $this;
}

/**
* Tells if the command is active.
*
Expand Down
1 change: 1 addition & 0 deletions src/Commands/Help.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ protected function showCommand(string $commandName) : void
{
$command = $this->console->getCommand($commandName);
if ($command === null) {
$this->setExitCode(1);
CLI::error(
$this->console->getLanguage()->render('cli', 'commandNotFound', [$commandName]),
\defined('TESTING') ? null : 1
Expand Down
50 changes: 40 additions & 10 deletions src/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ class Console
* applied, restored when the dispatch finishes.
*/
protected bool $previousAnsi = true;
/**
* The exit code reported by the last dispatched command, or 1 when the
* command was not found or failed validation.
*/
protected int $exitCode = 0;

/**
* Console constructor.
Expand Down Expand Up @@ -321,43 +326,66 @@ public function getCommandName() : string
}

/**
* Run the Console.
* Run the Console and return the resulting exit code.
*
* The code comes from the dispatched Command via setExitCode(), or is 1
* when the command was not found or failed validation. Entry points can
* forward it to the process with exit($console->run()).
*
* @return int The process exit code, 0 means success
*/
public function run() : void
public function run() : int
{
try {
$this->dispatch();
return $this->dispatch();
} finally {
CLI::setQuiet($this->previousQuiet);
CLI::setAnsi($this->previousAnsi);
}
}

/**
* Dispatch the current command.
* Get the exit code reported by the last dispatched command.
*
* @return int
*/
#[Pure]
public function getExitCode() : int
{
return $this->exitCode;
}

/**
* Dispatch the current command and return its exit code.
*
* @return int The process exit code, 0 means success
*/
protected function dispatch() : void
protected function dispatch() : int
{
$this->exitCode = 0;
if ($this->command === '') {
$this->command = 'index';
}
if ($this->isHelpRequested()) {
$help = $this->getCommand('help') ?? new Help($this);
$help->run();
return;
$this->exitCode = $help->getExitCode();
return $this->exitCode;
}
$command = $this->getCommand($this->command);
if ($command === null) {
$this->commandNotFound($this->command);
return;
return $this->exitCode;
}
$errors = $command->validate($this->arguments, $this->options);
if ($errors !== []) {
$this->validationFailed($errors);
return;
return $this->exitCode;
}
$command->applyDefaults($this);
$command->run();
$this->exitCode = $command->getExitCode();
return $this->exitCode;
}

/**
Expand All @@ -367,6 +395,7 @@ protected function dispatch() : void
*/
protected function validationFailed(array $errors) : void
{
$this->exitCode = 1;
$message = \implode(\PHP_EOL, $errors);
CLI::error(
CLI::style($message, ForegroundColor::brightRed),
Expand Down Expand Up @@ -405,6 +434,7 @@ protected function isHelpRequested() : bool
*/
protected function commandNotFound(string $command) : void
{
$this->exitCode = 1;
$message = $this->getLanguage()->render('cli', 'commandNotFound', [$command]);
$suggestion = $this->suggestCommand($command);
if ($suggestion !== null && $suggestion !== $command) {
Expand Down Expand Up @@ -454,12 +484,12 @@ protected function suggestCommand(string $command) : ?string
return $best;
}

public function exec(string $command) : void
public function exec(string $command) : int
{
$argumentValues = static::commandToArgs($command);
\array_unshift($argumentValues, 'removed');
$this->prepare($argumentValues);
$this->run();
return $this->run();
}

protected function reset() : void
Expand Down
42 changes: 42 additions & 0 deletions tests/ConsoleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -555,4 +555,46 @@ public function testHelpFallsBackToLegacyOptionsMap() : void
self::assertStringContainsString('Options', $contents);
self::assertStringContainsString('foo bar', $contents);
}

public function testSuccessfulCommandExitsWithZero() : void
{
$this->console->addCommand(new QuitterCommandMock($this->console));
$this->console->prepare(['file.php', 'quitter', '0']);
self::assertSame(0, $this->console->run());
self::assertSame(0, $this->console->getExitCode());
}

public function testCommandCanDecideTheExitCode() : void
{
$this->console->addCommand(new QuitterCommandMock($this->console));
$this->console->prepare(['file.php', 'quitter', '2']);
self::assertSame(2, $this->console->run());
self::assertSame(2, $this->console->getExitCode());
}

public function testUnknownCommandYieldsExitCodeOne() : void
{
$this->console->prepare(['file.php', 'nope-nope-nope']);
self::assertSame(1, $this->console->run());
self::assertSame(1, $this->console->getExitCode());
}

public function testValidationFailureYieldsExitCodeOne() : void
{
$command = new CommandMock($this->console);
$command->setArgumentDefinitions([
0 => ['type' => 'int', 'required' => true],
]);
$this->console->addCommand($command);
$this->console->prepare(['file.php', 'test', 'not-an-int']);
self::assertSame(1, $this->console->run());
self::assertSame(1, $this->console->getExitCode());
}

public function testHelpForUnknownCommandYieldsExitCodeOne() : void
{
$this->console->prepare(['file.php', 'help', 'nope-nope-nope']);
self::assertSame(1, $this->console->run());
self::assertSame(1, $this->console->getExitCode());
}
}
23 changes: 23 additions & 0 deletions tests/QuitterCommandMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
/*
* This file is part of Webisters CLI Library.
*
* (c) Hafiz Muhammad Moaz <thewebisters@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Tests\CLI;

use Framework\CLI\Command;

class QuitterCommandMock extends Command
{
protected string $name = 'quitter';

public function run() : void
{
$code = $this->getConsole()->getArgument(0);
$this->setExitCode($code === null ? 0 : (int) $code);
}
}
3 changes: 3 additions & 0 deletions tests/ValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@ public function testMissingNameFallsBackToTheKey() : void

public function testApplyDefaultsDirectlyFillsTheConsole() : void
{
// Reset the parsed command line so PHPUnit argv values cannot leak in
// and fill the argument slots before the defaults are applied.
$this->console->prepare(['phpunit']);
$command = new ValidatedCommandMock($this->console);
$command->setArgumentDefinitions([
0 => ['default' => 'fallback'],
Expand Down
Loading