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",
"require": {
"php": "^8.1",
"symfony/console": "^6.1",
"jrosset/betterphptoken": "^1.0",
"jrosset/collections": "^3.0",
"jrosset/extendedmonolog": "^2.0"
"php": "^8.1",
"jrosset/betterphptoken": "^1.0",
"jrosset/extendedmonolog": "^2.0",
"psr/log": "^2.0",
"symfony/console": "^6.1",
"symfony/event-dispatcher": "^6.1",
"voku/arrayy": "^7.9"
},
"autoload": {
"psr-4": {

@ -2,21 +2,14 @@
namespace jrosset\CliProgram;
use jrosset\CliProgram\Monolog\ConsoleOutputWithMonolog;
use jrosset\ExtendedMonolog\ExceptionLogger;
use jrosset\CliProgram\Monolog\TMonologApplication;
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
*/
class ApplicationWithCommandMonolog extends ApplicationWithCommandOutputInterface {
/**
* @var string The main log directory for Monolog: on subdirectory by command
*/
private string $logMainDirectory;
use TMonologApplication;
/**
* Initialization
@ -29,44 +22,4 @@ class ApplicationWithCommandMonolog extends ApplicationWithCommandOutputInterfac
parent::__construct($name, $version);
$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;
use jrosset\CliProgram\Output\TCommandOutputApplication;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/**
* An application with a by-command {@see OutputInterface}
*/
class ApplicationWithCommandOutputInterface extends Application {
/**
* 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));
}
use TCommandOutputApplication;
}

@ -127,7 +127,7 @@ class AutoDiscoveryDirectory implements IAutoDiscoverySpot {
$classes[$fileInfo->getRealPath()] = $this->applyAutoPrefixOnCommand($class->newInstance());
}
catch (ReflectionException $exception) {
catch (ReflectionException) {
continue;
}
}
@ -156,7 +156,12 @@ class AutoDiscoveryDirectory implements IAutoDiscoverySpot {
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);
for (; $currTokenGlobal < $nbTokens; $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;
use jrosset\Collections\Collection;
use jrosset\Collections\ICollection;
use Symfony\Component\Console\Command\Command;
/**
* An application with command auto discovery
*/
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
*
* @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)) {
$this->autoDiscoverySpots = new Collection();
$this->autoDiscoverySpots = new AutoDiscoverySpotsList();
}
return $this->autoDiscoverySpots;
}
@ -31,7 +30,8 @@ trait TAutoDiscoveryApplication {
*
* @return $this
*/
public function addAutoDiscoveredCommands (): self {
public function addAutoDiscoveredCommands (): static {
/** @var IAutoDiscoverySpot $autoDiscoverySpot */
foreach ($this->getAutoDiscoverySpots() as $autoDiscoverySpot) {
foreach ($autoDiscoverySpot->getCommands() as $command) {
$this->add($command);
@ -39,4 +39,18 @@ trait TAutoDiscoveryApplication {
}
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;
use jrosset\Collections\Collection;
use jrosset\Collections\ICollection;
use Symfony\Component\Console\Command\Command;
/**
@ -11,18 +9,18 @@ use Symfony\Component\Console\Command\Command;
*/
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
*
* @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)) {
$this->autoPrefixManagers = new Collection();
$this->autoPrefixManagers = new AutoPrefixManagersList();
}
return $this->autoPrefixManagers;
}
@ -34,18 +32,23 @@ trait TAutoPrefixManagement {
*
* @return Command The command
*/
protected function applyAutoPrefixOnCommand (Command $command): Command {
public function applyAutoPrefixOnCommand (Command $command): Command {
/** @var IAutoPrefixManager $autoPrefixManager */
foreach ($this->getAutoPrefixManagers() as $autoPrefixManager) {
if (($namesPrefix = $autoPrefixManager->getCommandPrefix($command)) !== null) {
if (mb_strlen($namesPrefix) > 0) {
$namesPrefix .= ':';
}
$command->setName($namesPrefix . $command->getName());
if (self::getNamePrefix($command->getName()) !== $namesPrefix) {
$command->setName($namesPrefix . $command->getName());
}
$aliases = $command->getAliases();
foreach ($aliases as &$alias) {
$alias = $namesPrefix . $alias;
if (self::getNamePrefix($alias) !== $namesPrefix) {
$alias = $namesPrefix . $alias;
}
}
$command->setAliases($aliases);
@ -54,4 +57,22 @@ trait TAutoPrefixManagement {
}
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;
use jrosset\CliProgram\CommandCall\CommandCall;
use jrosset\CliProgram\CommandCall\CommandCallsList;
use ReflectionClass;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/**
* A basic 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
*
* @param string|null $name The command name, Null = class name
* @param string ...$aliases The command aliases
* @param string|null $name The command name ; Null if the default one (cf. {@see static::getDefaultName()})
* @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) {
if ($name === null) {
$classShortName = (new ReflectionClass($this))->getShortName();
$name = mb_strtolower(mb_substr($classShortName, 0, 1)) . mb_substr($classShortName, 1);
public function __construct (?string $name = null, array|string|null $aliases = null, ?string $description = null, ?bool $hidden = null) {
parent::__construct($name);
$this->setAliases(is_string($aliases) ? [$aliases] : $aliases ?? static::getDefaultAliases());
$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);
$this->setAliases($aliases);
/** @var CommandCall $subcommand */
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;
use jrosset\CliProgram\Output\OutputWithLogger;
use Monolog\Level;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* 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 {
use TOutputInterfaceWithMonolog;
/**
* Option pour ne pas écrire un message dans Monolog
*/
public const OPTION_SKIP_MONOLOG = 4096;
class ConsoleOutputWithMonolog extends OutputWithLogger {
/**
* @param LoggerInterface $logger The Monolog Logger
* @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)
* @inheritDoc
*/
public function __construct (LoggerInterface $logger, int $verbosity = self::VERBOSITY_NORMAL, bool $decorated = null, OutputFormatterInterface $formatter = null) {
parent::__construct($verbosity, $decorated, $formatter);
$this->setLogger($logger);
protected function getLoggerLevelFromVerbosity (int $verbosity): Level {
if ($verbosity <= OutputInterface::VERBOSITY_QUIET) {
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 jrosset\CliProgram\Validation\Validators\InvalidValueException;
use jrosset\CliProgram\Validation\Validators\IValidator;
use ReflectionFunction;
use Stringable;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/**
* 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}
*/
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
*
* @throws Throwable If an argument or option is not valid
*/
protected function interact (InputInterface $input, OutputInterface $output): void {
parent::interact($input, $output);
$this->validate($input, $output);
protected function initialize (InputInterface $input, OutputInterface $output): void {
parent::setCode(
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
*
@ -59,12 +102,39 @@ trait TCommandWithValidation {
*/
protected function validateArguments (InputInterface $input, OutputInterface $output): void {
foreach ($this->argumentsValidator as $argumentName => $argumentValidator) {
if ($input->hasArgument($argumentName)) {
if (!$argumentValidator->validate($input->getArgument($argumentName))) {
//region Check the argument still exists
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));
}
$input->setArgument($argumentName, $argumentValidator->getValue());
}
//endregion
}
}
/**
@ -79,12 +149,39 @@ trait TCommandWithValidation {
*/
protected function validateOptions (InputInterface $input, OutputInterface $output): void {
foreach ($this->optionsValidator as $optionName => $optionValidator) {
if ($input->hasOption($optionName)) {
if (!$optionValidator->validate($input->getOption($optionName))) {
//region Check the option still exists
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));
}
$input->setOption($optionName, $optionValidator->getValue());
}
//endregion
}
}

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

@ -15,21 +15,27 @@ class DateTimeValidator extends AbstractDateTimeValidator {
* @var bool Is the time part mandatory ?
*/
private bool $timeMandatory;
/**
* @var DateTimeInterface The time part (if not mandatory)
*/
private DateTimeInterface $timePart;
/**
* 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) {
$this->setTimeMandatory($mandatoryDate);
public function __construct (bool $timeMandatory = false, ?DateTimeInterface $timePart = null) {
$this->setTimeMandatory($timeMandatory);
$this->setTimePart($timePart ?? (new DateTimeImmutable())->setTimestamp(0));
parent::__construct();
}
/**
* @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) {
return $default->format('Y-m-d H:i:s');
}
@ -56,9 +62,9 @@ class DateTimeValidator extends AbstractDateTimeValidator {
(int)$this->getInternalValidator()->getMatch('day'),
);
return $value->setTime(
(int)($this->getInternalValidator()->getMatch('hour') ?? 0),
(int)($this->getInternalValidator()->getMatch('minute') ?? 0),
(int)($this->getInternalValidator()->getMatch('second') ?? 0),
(int)($this->getInternalValidator()->getMatch('hour') ?? (int)$this->getTimePart()->format('%H')),
(int)($this->getInternalValidator()->getMatch('minute') ?? (int)$this->getTimePart()->format('%i')),
(int)($this->getInternalValidator()->getMatch('second') ?? (int)$this->getTimePart()->format('%s')),
);
}
@ -81,4 +87,24 @@ class DateTimeValidator extends AbstractDateTimeValidator {
$this->timeMandatory = $timeMandatory;
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
*/
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) {
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;
use jrosset\Collections\Collection;
use Arrayy\Type\StringCollection;
use ReflectionEnum;
use ReflectionException;
use UnitEnum;
@ -30,7 +30,7 @@ class EnumValidator extends BasedValidator {
public function __construct (string $enumClass) {
$this->enum = new ReflectionEnum($enumClass);
$enumCases = new Collection();
$enumCases = new StringCollection();
foreach ($this->enum->getCases() as $enumCase) {
$enumCases->add($enumCase->getName());
}
@ -40,10 +40,8 @@ class EnumValidator extends BasedValidator {
/**
* @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) {
$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
*
* @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

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

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

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

@ -29,7 +29,7 @@ class TimeValidator extends AbstractDateTimeValidator {
/**
* @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) {
return $default->format('H:i:s');
}

@ -6,12 +6,15 @@ use jrosset\CliProgram\ApplicationWithCommandMonolog;
use jrosset\CliProgram\AutoDiscovery\AutoDiscoveryDirectory;
use jrosset\CliProgram\AutoDiscovery\TAutoDiscoveryApplication;
use jrosset\CliProgram\AutoPrefix\AutoPrefixNamespaceManager;
use jrosset\CliProgram\Requirements\TRequirementsApplication;
class Application extends ApplicationWithCommandMonolog {
use TAutoDiscoveryApplication;
use TRequirementsApplication;
public function __construct (string $name = 'UNKNOWN', string $version = 'UNKNOWN') {
parent::__construct(__DIR__ . '/logs/', $name, $version);
$this->registerCommandRequirementsListener();
$spot = new AutoDiscoveryDirectory(__DIR__ . '/Commands');
$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 DateTimeInterface;
use jrosset\CliProgram\Monolog\ConsoleOutputWithMonolog;
use jrosset\CliProgram\CliCommand;
use jrosset\CliProgram\Output\OutputWithLogger;
use jrosset\CliProgram\Validation\CommandWithValidation;
use jrosset\CliProgram\Validation\Validators\DateValidator;
use jrosset\CliProgram\Validation\Validators\EnumValidator;
use jrosset\CliProgram\Validation\Validators\IntegerValidator;
use jrosset\Tests\Lang;
use jrosset\Tests\SuccessRequirement;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[CliCommand(null, 'Say hello')]
#[SuccessRequirement]
class Hello extends CommandWithValidation {
/**
* @inheritdoc
*/
protected static $defaultDescription = 'Say hello';
/**
* @inheritDoc
*/
public function __construct () {
parent::__construct('hello', 'bonjour');
public function __construct (?string $name = null) {
parent::__construct($name ?? 'hello', ['bonjour']);
}
/**
* @inheritDoc
*/
protected function configure () {
protected function configure (): void {
parent::configure();
$this->addArgument(
@ -45,15 +45,15 @@ class Hello extends CommandWithValidation {
$this->addOption(
'lang',
'l',
InputArgument::OPTIONAL,
InputOption::VALUE_REQUIRED,
'The lang',
Lang::English,
null,
new EnumValidator(Lang::class)
);
$this->addOption(
'repeat',
'r',
InputArgument::OPTIONAL,
InputOption::VALUE_OPTIONAL,
'The number of repeat',
1,
new IntegerValidator(1, null)
@ -64,9 +64,9 @@ class Hello extends CommandWithValidation {
* @inheritDoc
*/
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');
/** @var DateTimeInterface $day */
@ -78,7 +78,7 @@ class Hello extends CommandWithValidation {
for ($curr = 0; $curr < $repeat; $curr++) {
$output->writeln($text);
}
$output->writeln('FIN', ConsoleOutputWithMonolog::OPTION_SKIP_MONOLOG);
$output->writeln('FIN', OutputWithLogger::OPTION_SKIP_LOGGER);
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