Compare commits

...

26 Commits
2.x ... master

Author SHA1 Message Date
Julien Rosset 1d4075240e CommandArgumentList::addOption allowed without value (= true) 2 months ago
Julien Rosset b3d82fff86 Create CommandArgumentList 2 months ago
Julien Rosset 8152f9a673 Improve DateTimeValidator: add time part if not mandatory 2 months ago
Julien Rosset fefb3bf3c1 Fix command with validation when using -n/--no-interaction option 6 months ago
Julien Rosset 6c31a16e80 OutputWithLogger : fix method for “error” output 6 months ago
Julien Rosset 8055eb9132 OutputWithLogger : add method for “error” output 8 months ago
Julien Rosset 8bb34e1b6e Revert "CommandCall : allow command class name as command name"
This reverts commit 69418f455f.
9 months ago
Julien Rosset 69418f455f CommandCall : allow command class name as command name 9 months ago
Julien Rosset ab5cab163e BaseCommand : fix default name when using CliCommand 9 months ago
Julien Rosset 438e256ca3 Correction CliCommand : valeurs par défaut 9 months ago
Julien Rosset c1c36475de CommandCall: fix command name when AutoPrix or AutoDiscovery 9 months ago
Julien Rosset c40a14aba0 Add CliCommand attribute 9 months ago
Julien Rosset 26815fcac3 Add command requirements support 9 months ago
Julien Rosset 7d21bb492c Add method for normalizing command name in TAutoDiscoveryApplication 11 months ago
Julien Rosset 9d43547250 Suppression alerte exécution dans AutoDiscoveryDirectory 2 years ago
Julien Rosset a55725f19d Correction respect des sauts de lignes dans les loggers 2 years ago
Julien Rosset f641fb26eb Corrections documentation 2 years ago
Julien Rosset ddb2a7d4b5 CommandCall: add default value for argument list 2 years ago
Julien Rosset 6643ce813c Add CommandCall 2 years ago
Julien Rosset 6258c680df Replace jrosset/collections with voku/arrayy 2 years ago
Julien Rosset 98319dafb4 Fix arguments/options validation when "IS_ARRAY" is true 2 years ago
Julien Rosset 75a40b9c86 Add validator for directory and file 2 years ago
Julien Rosset 73c12fc82f Generalize application for any LoggerInterface 2 years ago
Julien Rosset 2981e01945 Add EmailValidator 2 years ago
Julien Rosset ede43fb01c Validator : do not check Null or False (default value / not provided) 2 years ago
Julien Rosset f69c7c8fa4 Validator : null also possible for valid default value 2 years ago

@ -15,11 +15,13 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"require": { "require": {
"php": "^8.1", "php": "^8.1",
"symfony/console": "^6.1", "jrosset/betterphptoken": "^1.0",
"jrosset/betterphptoken": "^1.0", "jrosset/extendedmonolog": "^2.0",
"jrosset/collections": "^3.0", "psr/log": "^2.0",
"jrosset/extendedmonolog": "^2.0" "symfony/console": "^6.1",
"symfony/event-dispatcher": "^6.1",
"voku/arrayy": "^7.9"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

@ -2,21 +2,14 @@
namespace jrosset\CliProgram; namespace jrosset\CliProgram;
use jrosset\CliProgram\Monolog\ConsoleOutputWithMonolog; use jrosset\CliProgram\Monolog\TMonologApplication;
use jrosset\ExtendedMonolog\ExceptionLogger;
use jrosset\ExtendedMonolog\LogDirectoryHandler; use jrosset\ExtendedMonolog\LogDirectoryHandler;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/** /**
* An application with a {@see LogDirectoryHandler Monolog log directory} for each command * An application with a {@see LogDirectoryHandler Monolog log directory} for each command
*/ */
class ApplicationWithCommandMonolog extends ApplicationWithCommandOutputInterface { class ApplicationWithCommandMonolog extends ApplicationWithCommandOutputInterface {
/** use TMonologApplication;
* @var string The main log directory for Monolog: on subdirectory by command
*/
private string $logMainDirectory;
/** /**
* Initialization * Initialization
@ -29,44 +22,4 @@ class ApplicationWithCommandMonolog extends ApplicationWithCommandOutputInterfac
parent::__construct($name, $version); parent::__construct($name, $version);
$this->setLogMainDirectory($logMainDirectory); $this->setLogMainDirectory($logMainDirectory);
} }
/**
* @inheritDoc
*/
protected function getOutputInterfaceForCommand (Command $command, InputInterface $input, OutputInterface $output): OutputInterface {
return new ConsoleOutputWithMonolog(
new ExceptionLogger(
$command->getName(),
[
new LogDirectoryHandler(
$this->getLogMainDirectory() . DIRECTORY_SEPARATOR
. str_replace(':', DIRECTORY_SEPARATOR, $command->getName())
),
]
),
$output->getVerbosity(),
$output->isDecorated(),
$output->getFormatter()
);
}
/**
* The main log directory for Monolog: on subdirectory by command
*
* @return string The main log directory for Monolog: on subdirectory by command
*/
public function getLogMainDirectory (): string {
return $this->logMainDirectory;
}
/**
* Set the main log directory for Monolog: on subdirectory by command
*
* @param string $logMainDirectory The main log directory for Monolog: on subdirectory by command
*
* @return $this
*/
public function setLogMainDirectory (string $logMainDirectory): self {
$this->logMainDirectory = $logMainDirectory;
return $this;
}
} }

@ -2,38 +2,13 @@
namespace jrosset\CliProgram; namespace jrosset\CliProgram;
use jrosset\CliProgram\Output\TCommandOutputApplication;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/** /**
* An application with a by-command {@see OutputInterface} * An application with a by-command {@see OutputInterface}
*/ */
class ApplicationWithCommandOutputInterface extends Application { class ApplicationWithCommandOutputInterface extends Application {
/** use TCommandOutputApplication;
* Get the {@see OutputInterface} for a command
*
* @param Command $command The command
* @param InputInterface $input The input
* @param OutputInterface $output The existing output
*
* @return OutputInterface The output for the command
*
* @throws Throwable On error
*
* @noinspection PhpUnusedParameterInspection
*/
protected function getOutputInterfaceForCommand (Command $command, InputInterface $input, OutputInterface $output): OutputInterface {
return $output;
}
/**
* @inheritDoc
* @throws Throwable
*/
protected function doRunCommand (Command $command, InputInterface $input, OutputInterface $output): int {
return parent::doRunCommand($command, $input, $this->getOutputInterfaceForCommand($command, $input, $output));
}
} }

@ -127,7 +127,7 @@ class AutoDiscoveryDirectory implements IAutoDiscoverySpot {
$classes[$fileInfo->getRealPath()] = $this->applyAutoPrefixOnCommand($class->newInstance()); $classes[$fileInfo->getRealPath()] = $this->applyAutoPrefixOnCommand($class->newInstance());
} }
catch (ReflectionException $exception) { catch (ReflectionException) {
continue; continue;
} }
} }
@ -156,7 +156,12 @@ class AutoDiscoveryDirectory implements IAutoDiscoverySpot {
continue; continue;
} }
$tokens = BetterPhpToken::tokenize($buffer); /*
* Comme on ne travaille que sur une partie du code (système de buffer par 512 caractères),
* l'analyse AST peut signaler des alertes si par exemple le code coupe au milieu d'un commentaire
* → ignore donc les alertes de BetterPhpToken::tokenize pour ne pas polluer les logs
*/
$tokens = @BetterPhpToken::tokenize($buffer);
$nbTokens = count($tokens); $nbTokens = count($tokens);
for (; $currTokenGlobal < $nbTokens; $currTokenGlobal++) { for (; $currTokenGlobal < $nbTokens; $currTokenGlobal++) {
$token = $tokens[$currTokenGlobal]; $token = $tokens[$currTokenGlobal];

@ -0,0 +1,19 @@
<?php
namespace jrosset\CliProgram\AutoDiscovery;
use Arrayy\Collection\AbstractCollection;
/**
* A list of command auto discovery spots
*
* @extends AbstractCollection<IAutoDiscoverySpot>
*/
class AutoDiscoverySpotsList extends AbstractCollection {
/**
* @inheritDoc
*/
public function getType (): string {
return IAutoDiscoverySpot::class;
}
}

@ -2,26 +2,25 @@
namespace jrosset\CliProgram\AutoDiscovery; namespace jrosset\CliProgram\AutoDiscovery;
use jrosset\Collections\Collection; use Symfony\Component\Console\Command\Command;
use jrosset\Collections\ICollection;
/** /**
* An application with command auto discovery * An application with command auto discovery
*/ */
trait TAutoDiscoveryApplication { trait TAutoDiscoveryApplication {
/** /**
* @var ICollection<IAutoDiscoverySpot> The list of discovery spots * @var AutoDiscoverySpotsList The list of discovery spots
*/ */
private ICollection $autoDiscoverySpots; private AutoDiscoverySpotsList $autoDiscoverySpots;
/** /**
* The list of discovery spots * The list of discovery spots
* *
* @return ICollection<IAutoDiscoverySpot> The list of discovery spots * @return AutoDiscoverySpotsList The list of discovery spots
*/ */
public function getAutoDiscoverySpots (): ICollection { public function getAutoDiscoverySpots (): AutoDiscoverySpotsList {
if (!isset($this->autoDiscoverySpots)) { if (!isset($this->autoDiscoverySpots)) {
$this->autoDiscoverySpots = new Collection(); $this->autoDiscoverySpots = new AutoDiscoverySpotsList();
} }
return $this->autoDiscoverySpots; return $this->autoDiscoverySpots;
} }
@ -31,7 +30,8 @@ trait TAutoDiscoveryApplication {
* *
* @return $this * @return $this
*/ */
public function addAutoDiscoveredCommands (): self { public function addAutoDiscoveredCommands (): static {
/** @var IAutoDiscoverySpot $autoDiscoverySpot */
foreach ($this->getAutoDiscoverySpots() as $autoDiscoverySpot) { foreach ($this->getAutoDiscoverySpots() as $autoDiscoverySpot) {
foreach ($autoDiscoverySpot->getCommands() as $command) { foreach ($autoDiscoverySpot->getCommands() as $command) {
$this->add($command); $this->add($command);
@ -39,4 +39,18 @@ trait TAutoDiscoveryApplication {
} }
return $this; return $this;
} }
/**
* Normalize command's name and aliases with auto prefixes (if any)
*
* @param Command $command The commande
*
* @return Command The command
*/
public function normalizeCommandNameAndAliases (Command $command): Command {
/** @var IAutoDiscoverySpot $autoDiscoverySpot */
foreach ($this->getAutoDiscoverySpots() as $autoDiscoverySpot) {
$command = $autoDiscoverySpot->applyAutoPrefixOnCommand($command);
}
return $command;
}
} }

@ -0,0 +1,19 @@
<?php
namespace jrosset\CliProgram\AutoPrefix;
use Arrayy\Collection\AbstractCollection;
/**
* A list of managers of commands auto prefix
*
* @extends AbstractCollection<IAutoPrefixManager>
*/
class AutoPrefixManagersList extends AbstractCollection {
/**
* @inheritDoc
*/
public function getType (): string {
return IAutoPrefixManager::class;
}
}

@ -2,8 +2,6 @@
namespace jrosset\CliProgram\AutoPrefix; namespace jrosset\CliProgram\AutoPrefix;
use jrosset\Collections\Collection;
use jrosset\Collections\ICollection;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
/** /**
@ -11,18 +9,18 @@ use Symfony\Component\Console\Command\Command;
*/ */
trait TAutoPrefixManagement { trait TAutoPrefixManagement {
/** /**
* @var ICollection<IAutoPrefixManager> The lists of manager for commands auto prefix * @var AutoPrefixManagersList The lists of manager for commands auto prefix
*/ */
private ICollection $autoPrefixManagers; private AutoPrefixManagersList $autoPrefixManagers;
/** /**
* The lists of manager for commands auto prefix * The lists of manager for commands auto prefix
* *
* @return ICollection<IAutoPrefixManager> The lists of manager for commands auto prefix * @return AutoPrefixManagersList The lists of manager for commands auto prefix
*/ */
public function getAutoPrefixManagers (): ICollection { public function getAutoPrefixManagers (): AutoPrefixManagersList {
if (!isset($this->autoPrefixManagers)) { if (!isset($this->autoPrefixManagers)) {
$this->autoPrefixManagers = new Collection(); $this->autoPrefixManagers = new AutoPrefixManagersList();
} }
return $this->autoPrefixManagers; return $this->autoPrefixManagers;
} }
@ -34,18 +32,23 @@ trait TAutoPrefixManagement {
* *
* @return Command The command * @return Command The command
*/ */
protected function applyAutoPrefixOnCommand (Command $command): Command { public function applyAutoPrefixOnCommand (Command $command): Command {
/** @var IAutoPrefixManager $autoPrefixManager */
foreach ($this->getAutoPrefixManagers() as $autoPrefixManager) { foreach ($this->getAutoPrefixManagers() as $autoPrefixManager) {
if (($namesPrefix = $autoPrefixManager->getCommandPrefix($command)) !== null) { if (($namesPrefix = $autoPrefixManager->getCommandPrefix($command)) !== null) {
if (mb_strlen($namesPrefix) > 0) { if (mb_strlen($namesPrefix) > 0) {
$namesPrefix .= ':'; $namesPrefix .= ':';
} }
$command->setName($namesPrefix . $command->getName()); if (self::getNamePrefix($command->getName()) !== $namesPrefix) {
$command->setName($namesPrefix . $command->getName());
}
$aliases = $command->getAliases(); $aliases = $command->getAliases();
foreach ($aliases as &$alias) { foreach ($aliases as &$alias) {
$alias = $namesPrefix . $alias; if (self::getNamePrefix($alias) !== $namesPrefix) {
$alias = $namesPrefix . $alias;
}
} }
$command->setAliases($aliases); $command->setAliases($aliases);
@ -54,4 +57,22 @@ trait TAutoPrefixManagement {
} }
return $command; return $command;
} }
/**
* Get the prefix from a command name or alias
*
* @param string $name The command name or alias
*
* @return string The prefix (empty if none)
*/
private static function getNamePrefix (string $name): string {
$lastPos = mb_strrpos($name, ':');
if ($lastPos === false) {
return '';
}
$prefix = mb_substr($name, 0, $lastPos);
if (mb_strlen($prefix) > 0) {
$prefix .= ':';
}
return $prefix;
}
} }

@ -2,26 +2,110 @@
namespace jrosset\CliProgram; namespace jrosset\CliProgram;
use jrosset\CliProgram\CommandCall\CommandCall;
use jrosset\CliProgram\CommandCall\CommandCallsList;
use ReflectionClass; use ReflectionClass;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/** /**
* A basic command * A basic command
*/ */
class BaseCommand extends Command { class BaseCommand extends Command {
/**
* @return string|null The command default name
*/
public static function getDefaultName (): ?string {
$reflectionClass = new ReflectionClass(static::class);
if ($attribute = $reflectionClass->getAttributes(CliCommand::class)) {
/** @var CliCommand $cliCommandAttribute */
$cliCommandAttribute = $attribute[0]->newInstance();
if ($cliCommandAttribute->name !== null) {
return $cliCommandAttribute->name;
}
}
if (($name = parent::getDefaultName()) !== null) {
return $name;
}
$classShortName = $reflectionClass->getShortName();
return mb_strtolower(mb_substr($classShortName, 0, 1)) . mb_substr($classShortName, 1);
}
/**
* @return string[] The command default aliases
*/
public static function getDefaultAliases (): array {
$reflectionClass = new ReflectionClass(static::class);
if ($attribute = $reflectionClass->getAttributes(CliCommand::class)) {
/** @var CliCommand $cliCommandAttribute */
$cliCommandAttribute = $attribute[0]->newInstance();
return $cliCommandAttribute->aliases;
}
return [];
}
/**
* @return string|null The command default description
*/
public static function getDefaultDescription (): ?string {
if ($attribute = (new ReflectionClass(static::class))->getAttributes(CliCommand::class)) {
/** @var CliCommand $cliCommandAttribute */
$cliCommandAttribute = $attribute[0]->newInstance();
return $cliCommandAttribute->description;
}
return parent::getDefaultDescription();
}
/**
* @return bool Is the command hidden from command list by default ?
*/
public static function getDefaultHidden (): bool {
if ($attribute = (new ReflectionClass(static::class))->getAttributes(CliCommand::class)) {
/** @var CliCommand $cliCommandAttribute */
$cliCommandAttribute = $attribute[0]->newInstance();
return $cliCommandAttribute->hidden;
}
return false;
}
/** /**
* Initialize the command * Initialize the command
* *
* @param string|null $name The command name, Null = class name * @param string|null $name The command name ; Null if the default one (cf. {@see static::getDefaultName()})
* @param string ...$aliases The command aliases * @param string[]|string|null $aliases The command aliases ; Null if the default one (cf. {@see static::getDefaultAliases()})
* @param string|null $description The command description ; Null if the default one (cf. {@see static::getDefaultDescription()})
* @param bool|null $hidden Is the command hidden from command list ? Null if the default one (cf. {@see static::getDefaultHidden()})
*/ */
public function __construct (?string $name = null, string ...$aliases) { public function __construct (?string $name = null, array|string|null $aliases = null, ?string $description = null, ?bool $hidden = null) {
if ($name === null) { parent::__construct($name);
$classShortName = (new ReflectionClass($this))->getShortName(); $this->setAliases(is_string($aliases) ? [$aliases] : $aliases ?? static::getDefaultAliases());
$name = mb_strtolower(mb_substr($classShortName, 0, 1)) . mb_substr($classShortName, 1); $this->setDescription($description ?? $this->getDescription());
$this->setHidden($hidden ?? static::getDefaultHidden());
}
/**
* Run a list of subcommands
*
* Stop à the first subcommand returning other than {@see Command::SUCCESS}
*
* @param CommandCallsList|CommandCall $subcommandList The subcommands to run
* @param OutputInterface $output The output
*
* @return int The last executed subcommand return code
*
* @throws Throwable If an error occurs
*/
public function runSubCommands (CommandCallsList|CommandCall $subcommandList, OutputInterface $output): int {
if (!$subcommandList instanceof CommandCallsList) {
$subcommandList = new CommandCallsList([$subcommandList]);
} }
parent::__construct($name); /** @var CommandCall $subcommand */
$this->setAliases($aliases); foreach ($subcommandList as $subcommand) {
if (($returnCode = $subcommand->run($this->getApplication(), $output)) !== Command::SUCCESS) {
return $returnCode;
}
}
return Command::SUCCESS;
} }
} }

@ -0,0 +1,47 @@
<?php
namespace jrosset\CliProgram;
use Attribute;
use jrosset\CliProgram\AutoDiscovery\IAutoDiscoverySpot;
use Symfony\Component\Console\Attribute\AsCommand;
/**
* Attribute to tag command
*
* Identical to {@see AsCommand} but allow empty command name (i.e., for {@see IAutoDiscoverySpot})
*/
#[Attribute(Attribute::TARGET_CLASS)]
class CliCommand {
/**
* @var string|null The commande name
*/
public readonly ?string $name;
/**
* @var string|null The command description
*/
public readonly ?string $description;
/**
* @var string[] The command aliases
*/
public readonly array $aliases;
/**
* @var bool Is the command hidden from command list ?
*/
public readonly bool $hidden;
/**
* Initialization
*
* @param string|null $name The commande name
* @param string|null $description The command description
* @param string[] $aliases The command aliases
* @param bool $hidden Is the command hidden from command list ?
*/
public function __construct (?string $name = null, ?string $description = null, array $aliases = [], bool $hidden = false) {
$this->name = $name;
$this->description = $description;
$this->aliases = $aliases;
$this->hidden = $hidden;
}
}

@ -0,0 +1,42 @@
<?php
namespace jrosset\CliProgram;
use jrosset\CliProgram\Output\OutputWrapper;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Helper methods
*/
abstract class CliHelper {
/**
* Get the “error” output if exists, else the output itself
*
* @param OutputInterface $output The output
*
* @return OutputInterface The “error” output if exists, else the output itself
*/
public static final function getErrorOutput (OutputInterface $output): OutputInterface {
return $output instanceof ConsoleOutputInterface || $output instanceof OutputWrapper ? $output->getErrorOutput() : $output;
}
/**
* Get the name of a command class
*
* @param Application $application The application
* @param class-string<Command> $commandClass The command class
*
* @return string|null The command name ; Null if not found
*/
public static final function getCommandNameFromClass (Application $application, string $commandClass): ?string {
foreach ($application->all() as $possibleCommand) {
if ($possibleCommand::class == $commandClass) {
return $possibleCommand->getName();
}
}
return null;
}
}

@ -0,0 +1,67 @@
<?php
namespace jrosset\CliProgram\CommandCall;
use Traversable;
/**
* A list of arguments for a command call
*/
class CommandArgumentList {
/**
* @var array<string, mixed> The arguments
*/
protected array $arguments;
/**
* Initialization
*
* @param Traversable|null $arguments The arguments
*/
public function __construct (?Traversable $arguments = null) {
$this->arguments = [];
if ($arguments !== null) {
foreach ($arguments as $name => $value) {
$this->arguments[$name] = $value;
}
}
}
/**
* The arguments
*
* @return array<string, mixed> The arguments
*/
public function getArguments (): array {
return $this->arguments;
}
/**
* Add an argument to the list
*
* Replace it if existing.
*
* @param string $name The argument name
* @param mixed $value The argument value
*
* @return $this
*/
public function addArgument (string $name, mixed $value): static {
$this->arguments[$name] = $value;
return $this;
}
/**
* Add an option to the list
*
* Replace it if existing.
*
* @param string $name The option name (with or without the leading dashes)
* @param mixed $value The option value
*
* @return $this
*/
public function addOption (string $name, mixed $value = true): static {
$this->arguments[(str_starts_with($name, '-') ? '' : '--') . $name] = $value;
return $this;
}
}

@ -0,0 +1,106 @@
<?php
namespace jrosset\CliProgram\CommandCall;
use Arrayy\Arrayy;
use jrosset\CliProgram\CliHelper;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/**
* A call to a command
*/
class CommandCall {
/**
* @var string The command name
*/
private string $commandName;
/**
* @var CommandArgumentList The command arguments
*/
private CommandArgumentList $commandArguments;
/**
* @param string|Command $commandName The new command name
* @param CommandArgumentList|Arrayy<string, mixed>|null $commandArguments The command new arguments
*/
public function __construct (string|Command $commandName, CommandArgumentList|Arrayy|null $commandArguments = null) {
$this->setCommandName($commandName);
$this->setCommandArguments($commandArguments ?? new CommandArgumentList());
}
/**
* Generate the input for the command call
*
* @return InputInterface Generate the input for the command call
*/
public function generateInput (): InputInterface {
return new ArrayInput(
array_merge(
[
'command' => $this->getCommandName(),
],
$this->getCommandArguments()->getArguments()
)
);
}
/**
* Run the command
*
* @param Application $app The application
* @param OutputInterface $output The output
*
* @return int The command return code
*
* @throws Throwable If an error occurs
*/
public function run (Application $app, OutputInterface $output): int {
return $app->doRun($this->generateInput(), $output);
}
/**
* The command name
*
* @return string The command name
*/
public function getCommandName (): string {
return $this->commandName;
}
/**
* Set the command name
*
* @param string|Command $commandName The new command name
*
* @return $this
*/
public function setCommandName (string|Command $commandName): self {
$this->commandName = $commandName instanceof Command
? CliHelper::getCommandNameFromClass($commandName->getApplication(), $commandName::class)
: $commandName;
return $this;
}
/**
* The command arguments
*
* @return Arrayy<string, mixed> The command arguments
*/
public function getCommandArguments (): CommandArgumentList {
return $this->commandArguments;
}
/**
* Set the command arguments
*
* @param CommandArgumentList|Arrayy $commandArguments The command new arguments
*
* @return $this
*/
public function setCommandArguments (CommandArgumentList|Arrayy $commandArguments): self {
$this->commandArguments = $commandArguments instanceof CommandArgumentList ? $commandArguments : new CommandArgumentList($commandArguments);
return $this;
}
}

@ -0,0 +1,19 @@
<?php
namespace jrosset\CliProgram\CommandCall;
use Arrayy\Collection\AbstractCollection;
/**
* A list of calls to a command
*
* @extends AbstractCollection<CommandCall>
*/
class CommandCallsList extends AbstractCollection {
/**
* @inheritDoc
*/
public function getType (): string {
return CommandCall::class;
}
}

@ -2,29 +2,39 @@
namespace jrosset\CliProgram\Monolog; namespace jrosset\CliProgram\Monolog;
use jrosset\CliProgram\Output\OutputWithLogger;
use Monolog\Level;
use Monolog\Logger;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Formatter\OutputFormatterInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
/** /**
* A {@see ConsoleOutput} with a {@see LoggerInterface Monolog Logger} * A generic output interface with a {@see LoggerInterface Monolog Logger}
*
* @implements OutputWithLogger<Logger>
*/ */
class ConsoleOutputWithMonolog extends ConsoleOutput { class ConsoleOutputWithMonolog extends OutputWithLogger {
use TOutputInterfaceWithMonolog;
/**
* Option pour ne pas écrire un message dans Monolog
*/
public const OPTION_SKIP_MONOLOG = 4096;
/** /**
* @param LoggerInterface $logger The Monolog Logger * @inheritDoc
* @param int $verbosity The verbosity level (one of the VERBOSITY constants in OutputInterface)
* @param bool|null $decorated Whether to decorate messages (null for auto-guessing)
* @param OutputFormatterInterface|null $formatter Output formatter instance (null to use default OutputFormatter)
*/ */
public function __construct (LoggerInterface $logger, int $verbosity = self::VERBOSITY_NORMAL, bool $decorated = null, OutputFormatterInterface $formatter = null) { protected function getLoggerLevelFromVerbosity (int $verbosity): Level {
parent::__construct($verbosity, $decorated, $formatter); if ($verbosity <= OutputInterface::VERBOSITY_QUIET) {
$this->setLogger($logger); return Level::Error;
}
elseif ($verbosity <= OutputInterface::VERBOSITY_NORMAL) {
return Level::Notice;
}
elseif ($verbosity <= OutputInterface::VERBOSITY_VERBOSE) {
return Level::Info;
}
elseif ($verbosity <= OutputInterface::VERBOSITY_VERY_VERBOSE) {
return Level::Info;
}
elseif ($verbosity <= OutputInterface::VERBOSITY_DEBUG) {
return Level::Debug;
}
else {
return Level::Notice;
}
} }
} }

@ -0,0 +1,57 @@
<?php
namespace jrosset\CliProgram\Monolog;
use jrosset\ExtendedMonolog\ExceptionLogger;
use jrosset\ExtendedMonolog\LogDirectoryHandler;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Implementation for an application with a {@see ConsoleOutputWithMonolog} and a {@see LogDirectoryHandler Monolog log directory} for each command
*/
trait TMonologApplication {
/**
* @var string The main log directory for Monolog: on subdirectory by command
*/
private string $logMainDirectory;
/**
* @inheritDoc
*/
protected function getOutputInterfaceForCommand (Command $command, InputInterface $input, OutputInterface $output): OutputInterface {
return new ConsoleOutputWithMonolog(
$output,
new ExceptionLogger(
$command->getName(),
[
new LogDirectoryHandler(
$this->getLogMainDirectory() . DIRECTORY_SEPARATOR
. str_replace(':', DIRECTORY_SEPARATOR, $command->getName())
),
]
)
);
}
/**
* The main log directory for Monolog: on subdirectory by command
*
* @return string The main log directory for Monolog: on subdirectory by command
*/
public function getLogMainDirectory (): string {
return $this->logMainDirectory;
}
/**
* Set the main log directory for Monolog: on subdirectory by command
*
* @param string $logMainDirectory The main log directory for Monolog: on subdirectory by command
*
* @return $this
*/
public function setLogMainDirectory (string $logMainDirectory): static {
$this->logMainDirectory = $logMainDirectory;
return $this;
}
}

@ -1,76 +0,0 @@
<?php
namespace jrosset\CliProgram\Monolog;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Provide a {@see LoggerInterface Monolog Logger} for {@see OutputInterface}
*/
trait TOutputInterfaceWithMonolog {
/**
* @var LoggerInterface|null The logger
*/
private ?LoggerInterface $logger;
/**
* The logger
*
* @return LoggerInterface|null The logger
*/
public function getLogger (): ?LoggerInterface {
return $this->logger;
}
/**
* Set the logger
*
* @param LoggerInterface|null $logger The logger
*
* @return $this
*/
public function setLogger (?LoggerInterface $logger): self {
$this->logger = $logger;
return $this;
}
/**
* @inheritDoc
*/
public function write($messages, bool $newline = false, int $options = 0): void {
if (!is_iterable($messages)) {
$messages = [$messages];
}
if ($this->logger !== null && (($options & ConsoleOutputWithMonolog::OPTION_SKIP_MONOLOG) !== ConsoleOutputWithMonolog::OPTION_SKIP_MONOLOG)) {
$verbosities = OutputInterface::VERBOSITY_QUIET | OutputInterface::VERBOSITY_NORMAL | OutputInterface::VERBOSITY_VERBOSE | OutputInterface::VERBOSITY_VERY_VERBOSE | OutputInterface::VERBOSITY_DEBUG;
$verbosity = $verbosities & $options ?: OutputInterface::VERBOSITY_NORMAL;
if ($verbosity <= OutputInterface::VERBOSITY_QUIET) {
$loggerLevel = Logger::ERROR;
}
elseif ($verbosity <= OutputInterface::VERBOSITY_NORMAL) {
$loggerLevel = Logger::NOTICE;
}
elseif ($verbosity <= OutputInterface::VERBOSITY_VERBOSE) {
$loggerLevel = Logger::INFO;
}
elseif ($verbosity <= OutputInterface::VERBOSITY_VERY_VERBOSE) {
$loggerLevel = Logger::INFO;
}
elseif ($verbosity <= OutputInterface::VERBOSITY_DEBUG) {
$loggerLevel = Logger::DEBUG;
}
else {
$loggerLevel = Logger::NOTICE;
}
foreach ($messages as $message) {
$this->logger->log($loggerLevel, strip_tags($message));
}
}
parent::write($messages, $newline, $options);
}
}

@ -0,0 +1,131 @@
<?php
/** @noinspection PhpMissingFieldTypeInspection */
namespace jrosset\CliProgram\Output;
use InvalidArgumentException;
use jrosset\CliProgram\CliHelper;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* A generic output interface with a {@see LoggerInterface PSR logger} connected
*
* @template TLogger of LoggerInterface
*/
class OutputWithLogger extends OutputWrapper {
/**
* Option to not write messages to logger
*
* @see static::write()
* @see static::writeln()
*/
public const OPTION_SKIP_LOGGER = 4096;
/**
* @var TLogger|null The logger
*/
private $logger;
/**
* Initialization
*
* @param OutputInterface $output The real output interface
* @param TLogger|null $logger The logger
*
* @noinspection PhpDocSignatureInspection
*/
public function __construct (OutputInterface $output, LoggerInterface $logger = null) {
parent::__construct($output);
$this->setLogger($logger);
}
/**
* @inheritDoc
*/
public function getErrorOutput (): static {
return new static(CliHelper::getErrorOutput($this->getOutput()), $this->logger);
}
/**
* The logger
*
* @return TLogger|null The logger
*/
public function getLogger () {
return $this->logger;
}
/**
* Set the logger
*
* @param TLogger|null $logger The logger
*
* @return $this
*
* @throws InvalidArgumentException If the logger is not null or an instance for {@see LoggerInterface}
*
* @noinspection PhpDocSignatureInspection
*/
public function setLogger (LoggerInterface $logger): self {
$this->logger = $logger;
return $this;
}
/**
* Write into the logger
*
* @param iterable|string $messages The messages to write
* @param bool $newline Newline at the end ?
* @param int $options A bitmask of options
*
* @return void
*/
protected function writeToLogger (iterable|string $messages, bool $newline = false, int $options = 0): void {
if ($this->logger === null || ($options & static::OPTION_SKIP_LOGGER) === static::OPTION_SKIP_LOGGER) {
return;
}
//region Calcul log level
$verbosities = OutputInterface::VERBOSITY_QUIET
| OutputInterface::VERBOSITY_NORMAL
| OutputInterface::VERBOSITY_VERBOSE
| OutputInterface::VERBOSITY_VERY_VERBOSE
| OutputInterface::VERBOSITY_DEBUG;
$loggerLevel = $this->getLoggerLevelFromVerbosity(
$verbosities & $options ? : OutputInterface::VERBOSITY_NORMAL
);
//endregion
if (!is_iterable($messages)) {
$messages = [$messages];
}
foreach ($messages as $message) {
$this->getLogger()->log($loggerLevel, strip_tags($message) . ($newline ? PHP_EOL : ''));
}
}
/**
* OGet the logger level from verbosity
*
* @param int $verbosity The verbosity
*
* @return mixed The logger level
*/
protected function getLoggerLevelFromVerbosity (int $verbosity): mixed {
return $verbosity;
}
/**
* @inheritDoc
*/
public function write (iterable|string $messages, bool $newline = false, int $options = 0) {
$this->writeToLogger($messages, $newline, $options);
return parent::write($messages, $newline, $options);
}
/**
* @inheritDoc
*/
public function writeln (iterable|string $messages, int $options = 0) {
$this->writeToLogger($messages, true, $options);
return parent::writeln($messages, $options);
}
}

@ -0,0 +1,121 @@
<?php
namespace jrosset\CliProgram\Output;
use jrosset\CliProgram\CliHelper;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* A wrapper for an OutputInterface
*/
abstract class OutputWrapper implements OutputInterface {
/**
* @var OutputInterface The real output interface
*/
private OutputInterface $output;
/**
* Initialization
*
* @param OutputInterface $output The real output interface
*/
public function __construct (OutputInterface $output) {
$this->output = $output;
}
/**
* The real output interface
*
* @return OutputInterface The real output interface
*/
public function getOutput (): OutputInterface {
return $this->output;
}
/**
* The “error” output
*
* @return static The “error” output
*/
public function getErrorOutput (): static {
return new static(CliHelper::getErrorOutput($this->getOutput()));
}
/**
* @inheritDoc
*/
public function write (iterable|string $messages, bool $newline = false, int $options = 0) {
return $this->getOutput()->write($messages, $newline, $options);
}
/**
* @inheritDoc
*/
public function writeln (iterable|string $messages, int $options = 0) {
return $this->getOutput()->writeln($messages, $options);
}
/**
* @inheritDoc
*/
public function setVerbosity (int $level) {
return $this->getOutput()->setVerbosity($level);
}
/**
* @inheritDoc
*/
public function getVerbosity (): int {
return $this->getOutput()->getVerbosity();
}
/**
* @inheritDoc
*/
public function isQuiet (): bool {
return $this->getOutput()->isQuiet();
}
/**
* @inheritDoc
*/
public function isVerbose (): bool {
return $this->getOutput()->isVerbose();
}
/**
* @inheritDoc
*/
public function isVeryVerbose (): bool {
return $this->getOutput()->isVeryVerbose();
}
/**
* @inheritDoc
*/
public function isDebug (): bool {
return $this->getOutput()->isDebug();
}
/**
* @inheritDoc
*/
public function setDecorated (bool $decorated) {
return $this->getOutput()->setDecorated($decorated);
}
/**
* @inheritDoc
*/
public function isDecorated (): bool {
return $this->getOutput()->isDecorated();
}
/**
* @inheritDoc
* @noinspection PhpInappropriateInheritDocUsageInspection
*/
public function setFormatter (OutputFormatterInterface $formatter) {
return $this->getOutput()->setFormatter($formatter);
}
/**
* @inheritDoc
*/
public function getFormatter (): OutputFormatterInterface {
return $this->getOutput()->getFormatter();
}
}

@ -0,0 +1,38 @@
<?php
namespace jrosset\CliProgram\Output;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/**
* Implementation for an application with a by-command {@see OutputInterface}
*/
trait TCommandOutputApplication {
/**
* Get the {@see OutputInterface} for a command
*
* @param Command $command The command
* @param InputInterface $input The input
* @param OutputInterface $output The existing output
*
* @return OutputInterface The output for the command
*
* @throws Throwable On error
*
* @noinspection PhpUnusedParameterInspection
*/
protected function getOutputInterfaceForCommand (Command $command, InputInterface $input, OutputInterface $output): OutputInterface {
return $output;
}
/**
* @inheritDoc
* @throws Throwable
*/
protected function doRunCommand (Command $command, InputInterface $input, OutputInterface $output): int {
return parent::doRunCommand($command, $input, $this->getOutputInterfaceForCommand($command, $input, $output));
}
}

@ -0,0 +1,24 @@
<?php
namespace jrosset\CliProgram\Requirements;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/**
* Interface for a command requirements
*/
interface IRequirements {
/**
* Check the requirements
*
* @param InputInterface $input The command input
* @param OutputInterface $output The command output
*
* @return void
*
* @throws Throwable If a requirement failed
*/
public function checkRequirements (InputInterface $input, OutputInterface $output): void;
}

@ -0,0 +1,72 @@
<?php
namespace jrosset\CliProgram\Requirements;
use jrosset\CliProgram\CliHelper;
use ReflectionAttribute;
use ReflectionClass;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Throwable;
/**
* An application with command requirements checking
*
* @see IRequirements
*/
trait TRequirementsApplication {
/**
* Register the listener for command requirements
*
* @param EventDispatcherInterface|null $dispatcher The event dispatcher is already existing
*
* @return void
*/
protected final function registerCommandRequirementsListener (?EventDispatcherInterface $dispatcher = null): void {
$dispatcher ??= new EventDispatcher();
$dispatcher->addListener(ConsoleEvents::COMMAND, $this->checkCommandRequirements(...));
$this->setDispatcher($dispatcher);
}
/**
* Check the requirements of a command
*
* @param ConsoleCommandEvent $event The command event
*
* @return void
*/
private function checkCommandRequirements (ConsoleCommandEvent $event): void {
$commandInput = $event->getInput();
$commandOutput = $event->getOutput();
try {
//region Check the command is valid
if (($command = $event->getCommand()) === null) {
return;
}
$commandReflection = new ReflectionClass($command);
//endregion
//region Check attribute requirements
foreach ($commandReflection->getAttributes(IRequirements::class, ReflectionAttribute::IS_INSTANCEOF) as $commandRequirementsAttribute) {
/** @var IRequirements $commandRequirementsAttributeInstance */
$commandRequirementsAttributeInstance = $commandRequirementsAttribute->newInstance();
$commandRequirementsAttributeInstance->checkRequirements($commandInput, $commandOutput);
}
//endregion
//region Contrôle pré-requis (implémentation directe)
if ($command instanceof IRequirements) {
$command->checkRequirements($commandInput, $commandOutput);
}
//endregion
}
catch (Throwable $exception) {
$this->renderThrowable($exception, CliHelper::getErrorOutput($commandOutput));
$event->disableCommand();
$event->stopPropagation();
return;
}
}
}

@ -5,13 +5,14 @@ namespace jrosset\CliProgram\Validation;
use Closure; use Closure;
use jrosset\CliProgram\Validation\Validators\InvalidValueException; use jrosset\CliProgram\Validation\Validators\InvalidValueException;
use jrosset\CliProgram\Validation\Validators\IValidator; use jrosset\CliProgram\Validation\Validators\IValidator;
use ReflectionFunction;
use Stringable; use Stringable;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/** /**
* Provide a value validation process ({@see IValidator}) to {@see self::addArgument()} and {@see self::addOption()} * Provide a value validation process ({@see IValidator}) to {@see self::addArgument()} and {@see self::addOption()}
@ -25,16 +26,58 @@ trait TCommandWithValidation {
* @var array<string, IValidator> The validators of each {@see InputOption} * @var array<string, IValidator> The validators of each {@see InputOption}
*/ */
private array $optionsValidator = []; private array $optionsValidator = [];
/**
* @var Closure|null The <i>real</i> code ({@see Command::$code} is used to perform input validation before execution) to execute when running this command.
* <br><br>If set, it overrides the code defined in the execute() method.
*/
private ?Closure $realCode = null;
/** /**
* @inheritDoc * @inheritDoc
*
* @throws Throwable If an argument or option is not valid
*/ */
protected function interact (InputInterface $input, OutputInterface $output): void { protected function initialize (InputInterface $input, OutputInterface $output): void {
parent::interact($input, $output); parent::setCode(
$this->validate($input, $output); function (InputInterface $input, OutputInterface $output): int {
$this->validate($input, $output);
return $this->realCode !== null
? ($this->realCode)($input, $output)
: $this->execute($input, $output);
}
);
parent::initialize($input, $output);
}
/**
* @inheritDoc
*/
public function setCode (callable $code): static {
if ($code instanceof Closure) {
/** @noinspection PhpUnhandledExceptionInspection */
$codeReflection = new ReflectionFunction($code);
if ($codeReflection->getClosureThis() === null) {
set_error_handler(
static function () {
}
);
try {
if ($rebindCode = Closure::bind($code, $this)) {
$code = $rebindCode;
}
}
finally {
restore_error_handler();
}
}
}
else {
$code = $code(...);
}
// {@see Command::$code} is used to perform input validation before execution, so set {@see self::$realCode} instead
$this->realCode = $code;
return $this;
} }
/** /**
* Validate the command input * Validate the command input
* *
@ -59,12 +102,39 @@ trait TCommandWithValidation {
*/ */
protected function validateArguments (InputInterface $input, OutputInterface $output): void { protected function validateArguments (InputInterface $input, OutputInterface $output): void {
foreach ($this->argumentsValidator as $argumentName => $argumentValidator) { foreach ($this->argumentsValidator as $argumentName => $argumentValidator) {
if ($input->hasArgument($argumentName)) { //region Check the argument still exists
if (!$argumentValidator->validate($input->getArgument($argumentName))) { if (!$input->hasArgument($argumentName)) {
continue;
}
//endregion
//region Get the argument value
$argumentValue = $input->getArgument($argumentName);
//endregion
//region Check the argument value is not Null
if ($argumentValue === null) {
// If the value is strictly Null (the default value), don't check it → skip
continue;
}
//endregion
//region Check the value is valid and replace it by the one extracted from the validator
if ($this->getDefinition()->getArgument($argumentName)->isArray() && is_array($argumentValue)) {
// This is an “array” argument → check and replace each value instead of the array itself
foreach ($argumentValue as &$argumentValueEntry) {
if (!$argumentValidator->validate($argumentValueEntry)) {
throw new InvalidValueException(sprintf('The "%s" argument has not a valid value', $argumentName));
}
$argumentValueEntry = $argumentValidator->getValue();
}
$input->setArgument($argumentName, $argumentValue);
}
else {
if (!$argumentValidator->validate($argumentValue)) {
throw new InvalidValueException(sprintf('The "%s" argument has not a valid value', $argumentName)); throw new InvalidValueException(sprintf('The "%s" argument has not a valid value', $argumentName));
} }
$input->setArgument($argumentName, $argumentValidator->getValue()); $input->setArgument($argumentName, $argumentValidator->getValue());
} }
//endregion
} }
} }
/** /**
@ -79,12 +149,39 @@ trait TCommandWithValidation {
*/ */
protected function validateOptions (InputInterface $input, OutputInterface $output): void { protected function validateOptions (InputInterface $input, OutputInterface $output): void {
foreach ($this->optionsValidator as $optionName => $optionValidator) { foreach ($this->optionsValidator as $optionName => $optionValidator) {
if ($input->hasOption($optionName)) { //region Check the option still exists
if (!$optionValidator->validate($input->getOption($optionName))) { if (!$input->hasOption($optionName)) {
continue;
}
//endregion
//region Get and check the option value
$optionValue = $input->getOption($optionName);
//endregion
//region Check the option value is NOT Null or False
if ($optionValue === null || $optionValue === false) {
// If the value is strictly Null (the default value or not provided) or False (the default value), don't check it → skip
continue;
}
//endregion
//region Check the value is valid and replace it by the one extracted from the validator
if ($this->getDefinition()->getOption($optionName)->isArray() && is_array($optionValue)) {
// This is an “array” argument → check and replace each value instead of the array itself
foreach ($optionValue as &$optionValueEntry) {
if (!$optionValidator->validate($optionValueEntry)) {
throw new InvalidValueException(sprintf('The "--%s" option has not a valid value', $optionName));
}
$optionValueEntry = $optionValidator->getValue();
}
$input->setArgument($optionName, $optionValue);
}
else {
if (!$optionValidator->validate($optionValue)) {
throw new InvalidValueException(sprintf('The "--%s" option has not a valid value', $optionName)); throw new InvalidValueException(sprintf('The "--%s" option has not a valid value', $optionName));
} }
$input->setOption($optionName, $optionValidator->getValue()); $input->setOption($optionName, $optionValidator->getValue());
} }
//endregion
} }
} }

@ -11,14 +11,14 @@ abstract class BasedValidator implements IValidator {
/** /**
* @var TValidator The internal validator * @var TValidator The internal validator
*/ */
private $internalValidator; private IValidator $internalValidator;
/** /**
* Create a validator * Create a validator
* *
* @param TValidator $internalValidator The internal validator * @param TValidator $internalValidator The internal validator
*/ */
public function __construct ($internalValidator) { public function __construct (IValidator $internalValidator) {
$this->internalValidator = $internalValidator; $this->internalValidator = $internalValidator;
} }
/** /**
@ -26,7 +26,7 @@ abstract class BasedValidator implements IValidator {
* *
* @return TValidator The internal validator * @return TValidator The internal validator
*/ */
protected function getInternalValidator () { protected function getInternalValidator (): IValidator {
return $this->internalValidator; return $this->internalValidator;
} }

@ -15,21 +15,27 @@ class DateTimeValidator extends AbstractDateTimeValidator {
* @var bool Is the time part mandatory ? * @var bool Is the time part mandatory ?
*/ */
private bool $timeMandatory; private bool $timeMandatory;
/**
* @var DateTimeInterface The time part (if not mandatory)
*/
private DateTimeInterface $timePart;
/** /**
* Create a validator * Create a validator
* *
* @param bool $mandatoryDate Is the time part mandatory ? * @param bool $timeMandatory Is the time part mandatory ?
* @param DateTimeInterface|null $timePart The time part (if not mandatory)
*/ */
public function __construct (bool $mandatoryDate = false) { public function __construct (bool $timeMandatory = false, ?DateTimeInterface $timePart = null) {
$this->setTimeMandatory($mandatoryDate); $this->setTimeMandatory($timeMandatory);
$this->setTimePart($timePart ?? (new DateTimeImmutable())->setTimestamp(0));
parent::__construct(); parent::__construct();
} }
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getValidDefault (mixed $default): string|bool|int|float|array { public function getValidDefault (mixed $default): string|bool|int|float|array|null {
if ($default instanceof DateTimeInterface) { if ($default instanceof DateTimeInterface) {
return $default->format('Y-m-d H:i:s'); return $default->format('Y-m-d H:i:s');
} }
@ -56,9 +62,9 @@ class DateTimeValidator extends AbstractDateTimeValidator {
(int)$this->getInternalValidator()->getMatch('day'), (int)$this->getInternalValidator()->getMatch('day'),
); );
return $value->setTime( return $value->setTime(
(int)($this->getInternalValidator()->getMatch('hour') ?? 0), (int)($this->getInternalValidator()->getMatch('hour') ?? (int)$this->getTimePart()->format('%H')),
(int)($this->getInternalValidator()->getMatch('minute') ?? 0), (int)($this->getInternalValidator()->getMatch('minute') ?? (int)$this->getTimePart()->format('%i')),
(int)($this->getInternalValidator()->getMatch('second') ?? 0), (int)($this->getInternalValidator()->getMatch('second') ?? (int)$this->getTimePart()->format('%s')),
); );
} }
@ -81,4 +87,24 @@ class DateTimeValidator extends AbstractDateTimeValidator {
$this->timeMandatory = $timeMandatory; $this->timeMandatory = $timeMandatory;
return $this; return $this;
} }
/**
* The time part (if not mandatory)
*
* @return DateTimeInterface The time part (if not mandatory)
*/
public function getTimePart (): DateTimeInterface {
return $this->timePart;
}
/**
* Set the time part (if not mandatory)
*
* @param DateTimeInterface $timePart The time part
*
* @return $this
*/
public function setTimePart (DateTimeInterface $timePart): self {
$this->timePart = $timePart;
return $this;
}
} }

@ -29,7 +29,7 @@ class DateValidator extends AbstractDateTimeValidator {
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getValidDefault (mixed $default): string|bool|int|float|array { public function getValidDefault (mixed $default): string|bool|int|float|array|null {
if ($default instanceof DateTimeInterface) { if ($default instanceof DateTimeInterface) {
return $default->format('Y-m-d'); return $default->format('Y-m-d');
} }

@ -0,0 +1,104 @@
<?php
namespace jrosset\CliProgram\Validation\Validators;
use InvalidArgumentException;
use Stringable;
/**
* An argument/option value validator for a directory path
*/
class DirectoryValidator implements IValidator {
use TInternalValueValidator;
/**
* @var FilesystemValidationOptionsList The options
*/
private FilesystemValidationOptionsList $options;
/**
* Initialization
*
* @param FilesystemValidationOptionsList|FilesystemValidationOption[]|FilesystemValidationOption|null $options The options
*/
public function __construct (FilesystemValidationOptionsList|array|FilesystemValidationOption|null $options = null) {
$this->setOptions($options);
}
/**
* Get a valid default value from the initial/given default value
*
* @param string|Stringable|null $default The initial/given default value
*
* @return string|bool|int|float|array|null The valid default value
*
* @throws InvalidArgumentException If the default value is not a string or null
* @throws InvalidArgumentException If the default directory doesn't match the options
*/
public function getValidDefault (mixed $default): string|bool|int|float|array|null {
if ($default === null) {
return null;
}
if (!is_string($default) && !$default instanceof Stringable) {
throw new InvalidArgumentException('The default value must be a string or null');
}
if ($this->getOptions()->contains(FilesystemValidationOption::IsReadable) && !is_readable($default)) {
throw new InvalidArgumentException('The default value is not readable');
}
if ($this->getOptions()->contains(FilesystemValidationOption::IsWritable) && !is_writable($default)) {
throw new InvalidArgumentException('The default value is not writable');
}
if ($this->getOptions()->contains(FilesystemValidationOption::MustExists) && !file_exists($default)) {
throw new InvalidArgumentException('The default value doesn\'t exist');
}
return $default;
}
/**
* @inheritDoc
*/
public function validate (mixed $value): bool {
if (!is_string($value) && !$value instanceof Stringable) {
return false;
}
if ($this->getOptions()->contains(FilesystemValidationOption::IsReadable->name) && !is_readable($value)) {
return false;
}
if ($this->getOptions()->contains(FilesystemValidationOption::IsWritable->name) && !is_writable($value)) {
return false;
}
if ($this->getOptions()->contains(FilesystemValidationOption::MustExists->name) && !file_exists($value)) {
return false;
}
$this->setValue($value);
return true;
}
/**
* The options
*
* @return FilesystemValidationOptionsList The options
*/
public function getOptions (): FilesystemValidationOptionsList {
return $this->options;
}
/**
* Replace the options
*
* @param FilesystemValidationOptionsList|FilesystemValidationOption[]|FilesystemValidationOption|null $options The options
*
* @return $this
*/
public function setOptions (FilesystemValidationOptionsList|array|FilesystemValidationOption|null $options = null): static {
$this->options = match (true) {
$options instanceof FilesystemValidationOptionsList => $options,
$options instanceof FilesystemValidationOption => new FilesystemValidationOptionsList([$options]),
is_array($options) => new FilesystemValidationOptionsList($options),
$options === null => new FilesystemValidationOptionsList(),
};
return $this;
}
}

@ -0,0 +1,23 @@
<?php
namespace jrosset\CliProgram\Validation\Validators;
/**
* An argument/option value validator expecting an email address
*/
class EmailValidator implements IValidator {
use TInternalValueValidator;
use TIdenticalValidDefaultValidator;
/**
* @inheritDoc
*/
public function validate (mixed $value): bool {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
return false;
}
$this->setValue($value);
return true;
}
}

@ -2,7 +2,7 @@
namespace jrosset\CliProgram\Validation\Validators; namespace jrosset\CliProgram\Validation\Validators;
use jrosset\Collections\Collection; use Arrayy\Type\StringCollection;
use ReflectionEnum; use ReflectionEnum;
use ReflectionException; use ReflectionException;
use UnitEnum; use UnitEnum;
@ -30,7 +30,7 @@ class EnumValidator extends BasedValidator {
public function __construct (string $enumClass) { public function __construct (string $enumClass) {
$this->enum = new ReflectionEnum($enumClass); $this->enum = new ReflectionEnum($enumClass);
$enumCases = new Collection(); $enumCases = new StringCollection();
foreach ($this->enum->getCases() as $enumCase) { foreach ($this->enum->getCases() as $enumCase) {
$enumCases->add($enumCase->getName()); $enumCases->add($enumCase->getName());
} }
@ -40,10 +40,8 @@ class EnumValidator extends BasedValidator {
/** /**
* @inheritDoc * @inheritDoc
*
* @throws ReflectionException
*/ */
public function getValidDefault (mixed $default): string|bool|int|float|array { public function getValidDefault (mixed $default): string|bool|int|float|array|null {
if ($default instanceof UnitEnum) { if ($default instanceof UnitEnum) {
$default = $default->name; $default = $default->name;
} }

@ -0,0 +1,71 @@
<?php
namespace jrosset\CliProgram\Validation\Validators;
use InvalidArgumentException;
/**
* An argument/option value validator for a file path
*/
class FileValidator extends DirectoryValidator {
/**
* @var string|null The allowed extensions (regex pattern)
*/
private ?string $extensionsPattern;
/**
* Initialization
*
* @param string|null $extensionsPattern The allowed extensions (regex pattern)
* @param FilesystemValidationOptionsList|FilesystemValidationOption[]|FilesystemValidationOption|null $options The options
*/
public function __construct (?string $extensionsPattern = null, FilesystemValidationOptionsList|FilesystemValidationOption|array|null $options = null) {
parent::__construct($options);
$this->setExtensionsPattern($extensionsPattern);
}
/**
* @inheritDoc
*/
public function getValidDefault (mixed $default): string|bool|int|float|array|null {
if (($default = parent::getValidDefault($default)) === null) {
return null;
}
if ($this->extensionsPattern !== null && preg_match($this->extensionsPattern, $default) !== 1) {
throw new InvalidArgumentException('The default value has not a valid extension');
}
return $default;
}
/**
* @inheritDoc
*/
public function validate (mixed $value): bool {
if (!parent::validate($value)) {
return false;
}
if ($this->extensionsPattern !== null && preg_match($this->extensionsPattern, $value) !== 1) {
return false;
}
return true;
}
/**
* The allowed extensions (regex pattern)
*
* @return string|null The allowed extensions (regex pattern)
*/
public function getExtensionsPattern (): ?string {
return $this->extensionsPattern;
}
/**
* Replace the allowed extensions
*
* @param string|null $extensionsPattern The allowed extensions (regex pattern)
*
* @return $this
*/
public function setExtensionsPattern (?string $extensionsPattern = null): static {
$this->extensionsPattern = $extensionsPattern;
return $this;
}
}

@ -0,0 +1,12 @@
<?php
namespace jrosset\CliProgram\Validation\Validators;
/**
* Options of a filesystem based validator
*/
enum FilesystemValidationOption {
case MustExists;
case IsReadable;
case IsWritable;
}

@ -0,0 +1,19 @@
<?php
namespace jrosset\CliProgram\Validation\Validators;
use Arrayy\Collection\AbstractCollection;
/**
* A list of options of a filesystem based validator
*
* @extends AbstractCollection<FilesystemValidationOption>
*/
class FilesystemValidationOptionsList extends AbstractCollection {
/**
* @inheritDoc
*/
public function getType (): string {
return FilesystemValidationOption::class;
}
}

@ -13,9 +13,9 @@ interface IValidator {
* *
* @param mixed $default The initial/given default value * @param mixed $default The initial/given default value
* *
* @return string|bool|int|float|array The valid default value * @return string|bool|int|float|array|null The valid default value
*/ */
public function getValidDefault (mixed $default): string|bool|int|float|array; public function getValidDefault (mixed $default): string|bool|int|float|array|null;
/** /**
* Validate a value * Validate a value

@ -2,8 +2,7 @@
namespace jrosset\CliProgram\Validation\Validators; namespace jrosset\CliProgram\Validation\Validators;
use jrosset\Collections\Collection; use Arrayy\Type\StringCollection;
use jrosset\Collections\ICollection;
/** /**
* An argument/option value validator based on a list of value * An argument/option value validator based on a list of value
@ -17,17 +16,17 @@ class ListValidator implements IValidator {
use TInternalValueValidator; use TInternalValueValidator;
/** /**
* @var ICollection The list of allowed values * @var StringCollection The list of allowed values
*/ */
private ICollection $allowedValues; private StringCollection $allowedValues;
/** /**
* Create a validator * Create a validator
* *
* @param ICollection|null $allowedValues The list of allowed values * @param StringCollection|null $allowedValues The list of allowed values
*/ */
public function __construct (?ICollection $allowedValues = null) { public function __construct (?StringCollection $allowedValues = null) {
$this->setAllowedValues($allowedValues ?? new Collection()); $this->setAllowedValues($allowedValues ?? new StringCollection());
} }
/** /**
@ -44,19 +43,19 @@ class ListValidator implements IValidator {
/** /**
* The list of allowed values * The list of allowed values
* *
* @return ICollection The list of allowed values * @return StringCollection The list of allowed values
*/ */
public function getAllowedValues (): ICollection { public function getAllowedValues (): StringCollection {
return $this->allowedValues; return $this->allowedValues;
} }
/** /**
* Set the list of allowed values * Set the list of allowed values
* *
* @param ICollection $allowedValues The list of allowed values * @param StringCollection $allowedValues The list of allowed values
* *
* @return $this * @return $this
*/ */
public function setAllowedValues (ICollection $allowedValues): self { public function setAllowedValues (StringCollection $allowedValues): self {
$this->allowedValues = $allowedValues; $this->allowedValues = $allowedValues;
return $this; return $this;
} }

@ -11,7 +11,7 @@ trait TIdenticalValidDefaultValidator {
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getValidDefault (mixed $default): string|bool|int|float|array { public function getValidDefault (mixed $default): string|bool|int|float|array|null {
return $default; return $default;
} }
} }

@ -13,7 +13,7 @@ namespace jrosset\CliProgram\Validation\Validators;
*/ */
trait TInternalValueValidator { trait TInternalValueValidator {
/** /**
* @var TValue|null The current value, after * @var TValue|null The current value, after it's validation
*/ */
private $value = null; private $value = null;

@ -29,7 +29,7 @@ class TimeValidator extends AbstractDateTimeValidator {
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getValidDefault (mixed $default): string|bool|int|float|array { public function getValidDefault (mixed $default): string|bool|int|float|array|null {
if ($default instanceof DateTimeInterface) { if ($default instanceof DateTimeInterface) {
return $default->format('H:i:s'); return $default->format('H:i:s');
} }

@ -6,12 +6,15 @@ use jrosset\CliProgram\ApplicationWithCommandMonolog;
use jrosset\CliProgram\AutoDiscovery\AutoDiscoveryDirectory; use jrosset\CliProgram\AutoDiscovery\AutoDiscoveryDirectory;
use jrosset\CliProgram\AutoDiscovery\TAutoDiscoveryApplication; use jrosset\CliProgram\AutoDiscovery\TAutoDiscoveryApplication;
use jrosset\CliProgram\AutoPrefix\AutoPrefixNamespaceManager; use jrosset\CliProgram\AutoPrefix\AutoPrefixNamespaceManager;
use jrosset\CliProgram\Requirements\TRequirementsApplication;
class Application extends ApplicationWithCommandMonolog { class Application extends ApplicationWithCommandMonolog {
use TAutoDiscoveryApplication; use TAutoDiscoveryApplication;
use TRequirementsApplication;
public function __construct (string $name = 'UNKNOWN', string $version = 'UNKNOWN') { public function __construct (string $name = 'UNKNOWN', string $version = 'UNKNOWN') {
parent::__construct(__DIR__ . '/logs/', $name, $version); parent::__construct(__DIR__ . '/logs/', $name, $version);
$this->registerCommandRequirementsListener();
$spot = new AutoDiscoveryDirectory(__DIR__ . '/Commands'); $spot = new AutoDiscoveryDirectory(__DIR__ . '/Commands');
$spot->getAutoPrefixManagers()->prepend(new AutoPrefixNamespaceManager('\\jrosset\\Tests\\Commands', 'test')); $spot->getAutoPrefixManagers()->prepend(new AutoPrefixNamespaceManager('\\jrosset\\Tests\\Commands', 'test'));

@ -0,0 +1,34 @@
<?php
namespace jrosset\Tests\Commands;
use jrosset\CliProgram\Validation\CommandWithValidation;
use jrosset\CliProgram\Validation\Validators\EmailValidator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Email extends CommandWithValidation {
/**
* @inheritDoc
*/
protected function configure () {
parent::configure();
$this->addArgument(
'email',
InputArgument::REQUIRED,
'The email address',
null,
new EmailValidator()
);
}
/**
* @inheritDoc
*/
protected function execute (InputInterface $input, OutputInterface $output): int {
$output->writeln('<info>' . $input->getArgument('email') . '</info>');
return Command::SUCCESS;
}
}

@ -0,0 +1,15 @@
<?php
namespace jrosset\Tests\Commands;
use jrosset\Tests\FailedRequirement;
#[FailedRequirement]
class FailedHello extends Hello {
/**
* @inheritDoc
*/
public function __construct () {
parent::__construct('failedHello');
}
}

@ -4,34 +4,34 @@ namespace jrosset\Tests\Commands;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeInterface; use DateTimeInterface;
use jrosset\CliProgram\Monolog\ConsoleOutputWithMonolog; use jrosset\CliProgram\CliCommand;
use jrosset\CliProgram\Output\OutputWithLogger;
use jrosset\CliProgram\Validation\CommandWithValidation; use jrosset\CliProgram\Validation\CommandWithValidation;
use jrosset\CliProgram\Validation\Validators\DateValidator; use jrosset\CliProgram\Validation\Validators\DateValidator;
use jrosset\CliProgram\Validation\Validators\EnumValidator; use jrosset\CliProgram\Validation\Validators\EnumValidator;
use jrosset\CliProgram\Validation\Validators\IntegerValidator; use jrosset\CliProgram\Validation\Validators\IntegerValidator;
use jrosset\Tests\Lang; use jrosset\Tests\Lang;
use jrosset\Tests\SuccessRequirement;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
#[CliCommand(null, 'Say hello')]
#[SuccessRequirement]
class Hello extends CommandWithValidation { class Hello extends CommandWithValidation {
/**
* @inheritdoc
*/
protected static $defaultDescription = 'Say hello';
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function __construct () { public function __construct (?string $name = null) {
parent::__construct('hello', 'bonjour'); parent::__construct($name ?? 'hello', ['bonjour']);
} }
/** /**
* @inheritDoc * @inheritDoc
*/ */
protected function configure () { protected function configure (): void {
parent::configure(); parent::configure();
$this->addArgument( $this->addArgument(
@ -45,15 +45,15 @@ class Hello extends CommandWithValidation {
$this->addOption( $this->addOption(
'lang', 'lang',
'l', 'l',
InputArgument::OPTIONAL, InputOption::VALUE_REQUIRED,
'The lang', 'The lang',
Lang::English, null,
new EnumValidator(Lang::class) new EnumValidator(Lang::class)
); );
$this->addOption( $this->addOption(
'repeat', 'repeat',
'r', 'r',
InputArgument::OPTIONAL, InputOption::VALUE_OPTIONAL,
'The number of repeat', 'The number of repeat',
1, 1,
new IntegerValidator(1, null) new IntegerValidator(1, null)
@ -64,9 +64,9 @@ class Hello extends CommandWithValidation {
* @inheritDoc * @inheritDoc
*/ */
protected function execute (InputInterface $input, OutputInterface $output): int { protected function execute (InputInterface $input, OutputInterface $output): int {
$output->writeln('Command : ' . __CLASS__, OutputInterface::VERBOSITY_DEBUG); $output->writeln('<info>Command : ' . __CLASS__ . '</info>', OutputInterface::VERBOSITY_DEBUG);
$text = $input->getOption('lang')->value; $text = ($input->getOption('lang') ?? Lang::English)->value;
$repeat = $input->getOption('repeat'); $repeat = $input->getOption('repeat');
/** @var DateTimeInterface $day */ /** @var DateTimeInterface $day */
@ -78,7 +78,7 @@ class Hello extends CommandWithValidation {
for ($curr = 0; $curr < $repeat; $curr++) { for ($curr = 0; $curr < $repeat; $curr++) {
$output->writeln($text); $output->writeln($text);
} }
$output->writeln('FIN', ConsoleOutputWithMonolog::OPTION_SKIP_MONOLOG); $output->writeln('FIN', OutputWithLogger::OPTION_SKIP_LOGGER);
return Command::SUCCESS; return Command::SUCCESS;
} }
} }

@ -0,0 +1,47 @@
<?php
namespace jrosset\Tests\Commands;
use jrosset\CliProgram\Validation\CommandWithValidation;
use jrosset\CliProgram\Validation\Validators\FilesystemValidationOption;
use jrosset\CliProgram\Validation\Validators\FileValidator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ReadFile extends CommandWithValidation {
/**
* @inheritdoc
*/
protected static $defaultDescription = 'Read a file';
/**
* @inheritDoc
*/
protected function configure (): void {
parent::configure();
$this->addArgument(
'file',
InputArgument::REQUIRED,
'The file to read',
null,
new FileValidator(
/** @lang PhpRegExp */ '#\.txt$#i',
FilesystemValidationOption::IsReadable
)
);
}
/**
* @inheritDoc
*/
protected function execute (InputInterface $input, OutputInterface $output): int {
if (($fileContent = file_get_contents($input->getArgument('file'))) === false) {
return Command::FAILURE;
}
$output->writeln($fileContent);
return Command::SUCCESS;
}
}

@ -0,0 +1,19 @@
<?php
namespace jrosset\Tests;
use Attribute;
use jrosset\CliProgram\Requirements\IRequirements;
use LogicException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[Attribute(Attribute::TARGET_CLASS)]
class FailedRequirement implements IRequirements {
/**
* @inheritDoc
*/
public function checkRequirements (InputInterface $input, OutputInterface $output): void {
throw new LogicException('The "foo" requirement failed');
}
}

@ -0,0 +1,17 @@
<?php
namespace jrosset\Tests;
use Attribute;
use jrosset\CliProgram\Requirements\IRequirements;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[Attribute(Attribute::TARGET_CLASS)]
class SuccessRequirement implements IRequirements {
/**
* @inheritDoc
*/
public function checkRequirements (InputInterface $input, OutputInterface $output): void {
}
}

@ -0,0 +1 @@
This is a file
Loading…
Cancel
Save