From 8142001295eb5dc841ef61187135a390a7de7f3e Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sat, 13 Jun 2026 04:32:47 +0300 Subject: [PATCH] feat(command): add verbosity levels (-q, -v, -vv) - Add Verbosity class with QUIET, NORMAL, VERBOSE, DEBUG constants - Add -q, -v, -vv global arguments to Runner - Add getVerbosity() and setVerbosity() to Runner - Add verbose() and debug() methods to Command - Gate info() and success() by verbosity (suppressed in quiet mode) - error() and warning() always shown regardless of verbosity - println() and prints() unchanged (unconditional primitives) - Verbosity flags stripped from args passed to commands Closes #46 --- WebFiori/Cli/Command.php | 51 +++- WebFiori/Cli/Runner.php | 88 +++++-- WebFiori/Cli/Verbosity.php | 28 +++ tests/WebFiori/Tests/Cli/RunnerTest.php | 19 +- tests/WebFiori/Tests/Cli/VerbosityTest.php | 258 +++++++++++++++++++++ 5 files changed, 422 insertions(+), 22 deletions(-) create mode 100644 WebFiori/Cli/Verbosity.php create mode 100644 tests/WebFiori/Tests/Cli/VerbosityTest.php diff --git a/WebFiori/Cli/Command.php b/WebFiori/Cli/Command.php index 882bc8e..aef9604 100644 --- a/WebFiori/Cli/Command.php +++ b/WebFiori/Cli/Command.php @@ -346,6 +346,17 @@ public function confirm(string $confirmTxt, ?bool $default = null) : bool { public function createProgressBar(int $total = 100): ProgressBar { return new ProgressBar($this->getOutputStream(), $total); } + + /** + * Display a message only when verbosity is DEBUG (-vv). + * + * @param string $message The message that will be shown. + */ + public function debug(string $message): void { + if ($this->getVerbosityLevel() >= Verbosity::DEBUG) { + $this->printMsg($message, 'Debug', 'gray'); + } + } /** * Display a message that represents an error. * @@ -712,13 +723,15 @@ public function hasArg(string $argName) : bool { * Display a message that represents extra information. * * The message will be prefixed with the string 'Info:' in - * blue. + * blue. Suppressed in quiet mode. * * @param string $message The message that will be shown. * */ public function info(string $message): void { - $this->printMsg($message, 'Info', 'blue'); + if ($this->getVerbosityLevel() >= Verbosity::NORMAL) { + $this->printMsg($message, 'Info', 'blue'); + } } /** * Checks if an argument is provided in the CLI or not. @@ -1256,13 +1269,16 @@ public function setOwner(?Runner $owner = null): void { /** * Display a message that represents a success status. * - * The message will be prefixed with the string "Success:" in green. + * The message will be prefixed with the string "Success:" in green. + * Suppressed in quiet mode. * * @param string $message The message that will be displayed. * */ public function success(string $message): void { - $this->printMsg($message, 'Success', 'light-green'); + if ($this->getVerbosityLevel() >= Verbosity::NORMAL) { + $this->printMsg($message, 'Success', 'light-green'); + } } /** @@ -1422,6 +1438,17 @@ public function table(array $data, array $headers = [], array $options = []): Co return $this; } + + /** + * Display a message only when verbosity is VERBOSE (-v) or higher. + * + * @param string $message The message that will be shown. + */ + public function verbose(string $message): void { + if ($this->getVerbosityLevel() >= Verbosity::VERBOSE) { + $this->printMsg($message, 'Verbose', 'cyan'); + } + } /** * Display a message that represents a warning. * @@ -1603,6 +1630,21 @@ private function getTerminalWidth(): int { return 80; } + /** + * Returns the current verbosity level from the Runner, or NORMAL as default. + * + * @return int One of the Verbosity constants. + */ + private function getVerbosityLevel(): int { + $owner = $this->getOwner(); + + if ($owner !== null) { + return $owner->getVerbosity(); + } + + return Verbosity::NORMAL; + } + /** * Checks if ANSI output is enabled for this command. * @@ -1620,6 +1662,7 @@ private function isAnsiEnabled(): bool { return $this->isArgProvided('--ansi'); } + private function parseArgsHelper() : bool { $options = $this->getArgs(); $invalidArgsVals = []; diff --git a/WebFiori/Cli/Runner.php b/WebFiori/Cli/Runner.php index e566542..940e223 100644 --- a/WebFiori/Cli/Runner.php +++ b/WebFiori/Cli/Runner.php @@ -116,6 +116,13 @@ class Runner { */ private $signalHandler; + /** + * The current verbosity level. + * + * @var int + */ + private $verbosity; + /** * Creates new instance of the class. */ @@ -132,6 +139,7 @@ public function __construct() { $this->afterRunPool = []; $this->signalHandler = null; $this->shutdownRequested = false; + $this->verbosity = Verbosity::NORMAL; // Initialize discovery properties $this->commandDiscovery = null; @@ -146,12 +154,25 @@ public function __construct() { ArgumentOption::OPTIONAL => true, ArgumentOption::DESCRIPTION => 'Disable ANSI colored output.' ]); + $this->addArg('-q', [ + ArgumentOption::OPTIONAL => true, + ArgumentOption::DESCRIPTION => 'Quiet mode. Suppress non-critical output.' + ]); + $this->addArg('-v', [ + ArgumentOption::OPTIONAL => true, + ArgumentOption::DESCRIPTION => 'Verbose output.' + ]); + $this->addArg('-vv', [ + ArgumentOption::OPTIONAL => true, + ArgumentOption::DESCRIPTION => 'Debug output (most verbose).' + ]); $this->setBeforeStart(function (Runner $r) { if (count($r->getArgsVector()) == 0) { $r->setArgsVector($_SERVER['argv']); } $r->checkIsInteractive(); $r->resolveAnsi(); + $r->resolveVerbosity(); }); $this->register(new HelpCommand(), ['-h']); $this->setDefaultCommand('help'); @@ -586,6 +607,15 @@ public function getSignalHandler(): ?SignalHandler { return $this->signalHandler; } + /** + * Returns the current verbosity level. + * + * @return int One of the Verbosity constants. + */ + public function getVerbosity(): int { + return $this->verbosity; + } + /** * Check if an alias is registered. * @@ -1095,6 +1125,19 @@ public function setSignalHandler(int $signal, callable $handler): Runner { return $this; } + /** + * Sets the verbosity level. + * + * @param int $level One of the Verbosity constants. + * + * @return Runner The method returns same instance for chaining. + */ + public function setVerbosity(int $level): Runner { + $this->verbosity = $level; + + return $this; + } + /** * Determines if ANSI output should be used based on environment detection. * @@ -1139,7 +1182,7 @@ public function start(): int { if ($this->isInteractive()) { if (in_array('--no-color', $this->getArgsVector())) { $this->isAnsi = false; - } elseif (in_array('--ansi', $this->getArgsVector())) { + } else if (in_array('--ansi', $this->getArgsVector())) { $this->isAnsi = true; } $this->printMsg('Running in interactive mode.', '>>', 'blue'); @@ -1228,7 +1271,7 @@ private function readInteractive(): array { $argsArr = strlen($input) != 0 ? explode(' ', $input) : []; - $argsArr = $this->removeAnsiArgs($argsArr); + $argsArr = $this->removeGlobalFlags($argsArr); // Preprocess help patterns $argsArr = $this->preprocessHelpPattern($argsArr); @@ -1280,6 +1323,15 @@ private function registerCommandSignalHandlers(Command $c): void { } } } + + private function removeCommandSignalHandlers(Command $c): void { + if ($this->signalHandler !== null) { + foreach ($c->getSignalHandlers() as $signal => $handler) { + $this->signalHandler->remove($signal); + } + $c->clearSignalHandlers(); + } + } /** * Removes --ansi and --no-color flags from an arguments array. * @@ -1287,16 +1339,17 @@ private function registerCommandSignalHandlers(Command $c): void { * * @return array The filtered arguments array. */ - private function removeAnsiArgs(array $argsArr): array { + private function removeGlobalFlags(array $argsArr): array { + $flags = ['--ansi', '--no-color', '-q', '-v', '-vv']; $tempArgs = []; foreach ($argsArr as $argName => $val) { if (gettype($argName) == 'integer') { - if ($val != '--ansi' && $val != '--no-color') { + if (!in_array($val, $flags)) { $tempArgs[] = $val; } } else { - if ($argName != '--ansi' && $argName != '--no-color') { + if (!in_array($argName, $flags)) { $tempArgs[$argName] = $val; } } @@ -1305,21 +1358,24 @@ private function removeAnsiArgs(array $argsArr): array { return $tempArgs; } - private function removeCommandSignalHandlers(Command $c): void { - if ($this->signalHandler !== null) { - foreach ($c->getSignalHandlers() as $signal => $handler) { - $this->signalHandler->remove($signal); - } - $c->clearSignalHandlers(); - } - } - private function resolveAnsi(): void { if ($this->outputStream instanceof StdOut) { $this->isAnsi = self::shouldUseAnsi(); } } + private function resolveVerbosity(): void { + $args = $this->getArgsVector(); + + if (in_array('-vv', $args)) { + $this->verbosity = Verbosity::DEBUG; + } else if (in_array('-v', $args)) { + $this->verbosity = Verbosity::VERBOSE; + } else if (in_array('-q', $args)) { + $this->verbosity = Verbosity::QUIET; + } + } + /** * Run the command line as single run. * @@ -1330,11 +1386,11 @@ private function run(): int { if (in_array('--no-color', $argsArr)) { $this->isAnsi = false; - } elseif (in_array('--ansi', $argsArr)) { + } else if (in_array('--ansi', $argsArr)) { $this->isAnsi = true; } - $argsArr = $this->removeAnsiArgs($argsArr); + $argsArr = $this->removeGlobalFlags($argsArr); // Preprocess help patterns for non-interactive mode $argsArr = $this->preprocessHelpPattern($argsArr); diff --git a/WebFiori/Cli/Verbosity.php b/WebFiori/Cli/Verbosity.php new file mode 100644 index 0000000..9c8f4df --- /dev/null +++ b/WebFiori/Cli/Verbosity.php @@ -0,0 +1,28 @@ +assertTrue($runner->addArg('global-arg', [ ArgumentOption::OPTIONAL => true ])); - $this->assertEquals(3, count($runner->getArgs())); + $this->assertEquals(6, count($runner->getArgs())); $runner->removeArgument('--ansi'); - $this->assertEquals(2, count($runner->getArgs())); + $this->assertEquals(5, count($runner->getArgs())); $this->assertFalse($runner->hasArg('--ansi')); $runner->register(new Command00()); $this->assertEquals(2, count($runner->getCommands())); // help + super-hero @@ -151,6 +151,9 @@ public function testRunner05() { // Don't register HelpCommand again - it's already automatically registered $runner->removeArgument('--ansi'); $runner->removeArgument('--no-color'); + $runner->removeArgument('-q'); + $runner->removeArgument('-v'); + $runner->removeArgument('-vv'); $runner->setDefaultCommand('help'); $runner->setInputs([]); $this->assertEquals(0, $runner->runCommand(null, [])); @@ -174,6 +177,9 @@ public function testRunner06() { "Global Arguments:\n", " --ansi:[Optional] Force the use of ANSI output.\n", " --no-color:[Optional] Disable ANSI colored output.\n", + " -q:[Optional] Quiet mode. Suppress non-critical output.\n", + " -v:[Optional] Verbose output.\n", + " -vv:[Optional] Debug output (most verbose).\n", "Available Commands:\n", " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n", " super-hero: A command to display hero's name.\n" @@ -201,6 +207,9 @@ public function testRunner07() { "\e[1;93mGlobal Arguments:\e[0m\n", "\e[1;33m --ansi:\e[0m[Optional] Force the use of ANSI output.\n", "\e[1;33m --no-color:\e[0m[Optional] Disable ANSI colored output.\n", + "\e[1;33m -q:\e[0m[Optional] Quiet mode. Suppress non-critical output.\n", + "\e[1;33m -v:\e[0m[Optional] Verbose output.\n", + "\e[1;33m -vv:\e[0m[Optional] Debug output (most verbose).\n", "\e[1;93mAvailable Commands:\e[0m\n", "\e[1;33m help\e[0m: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n", "\e[1;33m super-hero\e[0m: A command to display hero's name.\n" @@ -231,6 +240,9 @@ public function testRunner09() { $runner = new Runner(); $runner->removeArgument('--ansi'); $runner->removeArgument('--no-color'); + $runner->removeArgument('-q'); + $runner->removeArgument('-v'); + $runner->removeArgument('-vv'); $runner->register(new Command00()); // Don't register HelpCommand - it's automatically registered $runner->setDefaultCommand('help'); @@ -337,6 +349,9 @@ public function testRunner13() { "Global Arguments:\n", " --ansi:[Optional] Force the use of ANSI output.\n", " --no-color:[Optional] Disable ANSI colored output.\n", + " -q:[Optional] Quiet mode. Suppress non-critical output.\n", + " -v:[Optional] Verbose output.\n", + " -vv:[Optional] Debug output (most verbose).\n", "Available Commands:\n", " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n", " super-hero: A command to display hero's name.\n", diff --git a/tests/WebFiori/Tests/Cli/VerbosityTest.php b/tests/WebFiori/Tests/Cli/VerbosityTest.php new file mode 100644 index 0000000..bed384d --- /dev/null +++ b/tests/WebFiori/Tests/Cli/VerbosityTest.php @@ -0,0 +1,258 @@ +error('error msg'); + $this->warning('warning msg'); + $this->info('info msg'); + $this->success('success msg'); + $this->verbose('verbose msg'); + $this->debug('debug msg'); + $this->println('always shown'); + + return 0; + } +} + +class VerbosityTest extends CommandTestCase { + /** + * @test + */ + public function testVerbosityConstants() { + $this->assertEquals(0, Verbosity::QUIET); + $this->assertEquals(1, Verbosity::NORMAL); + $this->assertEquals(2, Verbosity::VERBOSE); + $this->assertEquals(3, Verbosity::DEBUG); + } + + /** + * @test + */ + public function testDefaultVerbosityIsNormal() { + $runner = new Runner(); + $runner->reset(); + $this->assertEquals(Verbosity::NORMAL, $runner->getVerbosity()); + } + + /** + * @test + */ + public function testSetVerbosity() { + $runner = new Runner(); + $runner->reset(); + + $result = $runner->setVerbosity(Verbosity::QUIET); + $this->assertSame($runner, $result); + $this->assertEquals(Verbosity::QUIET, $runner->getVerbosity()); + + $runner->setVerbosity(Verbosity::VERBOSE); + $this->assertEquals(Verbosity::VERBOSE, $runner->getVerbosity()); + + $runner->setVerbosity(Verbosity::DEBUG); + $this->assertEquals(Verbosity::DEBUG, $runner->getVerbosity()); + } + + /** + * @test + */ + public function testQuietModeViaFlag() { + $runner = new Runner(); + $runner->reset(); + $runner->register(new VerbosityTestCommand()); + $runner->setInputs([]); + $runner->setArgsVector(['main.php', 'verb-test', '-q']); + $runner->start(); + + $output = $runner->getOutput(); + $outputStr = implode('', $output); + + // error and warning always shown + $this->assertStringContainsString('error msg', $outputStr); + $this->assertStringContainsString('warning msg', $outputStr); + // println always shown + $this->assertStringContainsString('always shown', $outputStr); + // info and success suppressed + $this->assertStringNotContainsString('info msg', $outputStr); + $this->assertStringNotContainsString('success msg', $outputStr); + // verbose and debug suppressed + $this->assertStringNotContainsString('verbose msg', $outputStr); + $this->assertStringNotContainsString('debug msg', $outputStr); + } + + /** + * @test + */ + public function testNormalMode() { + $output = $this->executeSingleCommand(new VerbosityTestCommand()); + $outputStr = implode('', $output); + + // error, warning, info, success shown + $this->assertStringContainsString('error msg', $outputStr); + $this->assertStringContainsString('warning msg', $outputStr); + $this->assertStringContainsString('info msg', $outputStr); + $this->assertStringContainsString('success msg', $outputStr); + $this->assertStringContainsString('always shown', $outputStr); + // verbose and debug suppressed + $this->assertStringNotContainsString('verbose msg', $outputStr); + $this->assertStringNotContainsString('debug msg', $outputStr); + } + + /** + * @test + */ + public function testVerboseModeViaFlag() { + $runner = new Runner(); + $runner->reset(); + $runner->register(new VerbosityTestCommand()); + $runner->setInputs([]); + $runner->setArgsVector(['main.php', 'verb-test', '-v']); + $runner->start(); + + $output = $runner->getOutput(); + $outputStr = implode('', $output); + + // everything except debug shown + $this->assertStringContainsString('error msg', $outputStr); + $this->assertStringContainsString('warning msg', $outputStr); + $this->assertStringContainsString('info msg', $outputStr); + $this->assertStringContainsString('success msg', $outputStr); + $this->assertStringContainsString('verbose msg', $outputStr); + $this->assertStringContainsString('always shown', $outputStr); + // debug suppressed + $this->assertStringNotContainsString('debug msg', $outputStr); + } + + /** + * @test + */ + public function testDebugModeViaFlag() { + $runner = new Runner(); + $runner->reset(); + $runner->register(new VerbosityTestCommand()); + $runner->setInputs([]); + $runner->setArgsVector(['main.php', 'verb-test', '-vv']); + $runner->start(); + + $output = $runner->getOutput(); + $outputStr = implode('', $output); + + // everything shown + $this->assertStringContainsString('error msg', $outputStr); + $this->assertStringContainsString('warning msg', $outputStr); + $this->assertStringContainsString('info msg', $outputStr); + $this->assertStringContainsString('success msg', $outputStr); + $this->assertStringContainsString('verbose msg', $outputStr); + $this->assertStringContainsString('debug msg', $outputStr); + $this->assertStringContainsString('always shown', $outputStr); + } + + /** + * @test + */ + public function testSetVerbosityProgrammatically() { + $runner = new Runner(); + $runner->reset(); + $runner->setVerbosity(Verbosity::QUIET); + $runner->register(new VerbosityTestCommand()); + $runner->setInputs([]); + $runner->setArgsVector(['main.php', 'verb-test']); + $runner->start(); + + $output = $runner->getOutput(); + $outputStr = implode('', $output); + + $this->assertStringContainsString('error msg', $outputStr); + $this->assertStringNotContainsString('info msg', $outputStr); + } + + /** + * @test + */ + public function testFlagOverridesProgrammaticVerbosity() { + $runner = new Runner(); + $runner->reset(); + $runner->setVerbosity(Verbosity::QUIET); + $runner->register(new VerbosityTestCommand()); + $runner->setInputs([]); + // -vv flag should override the programmatic QUIET setting + $runner->setArgsVector(['main.php', 'verb-test', '-vv']); + $runner->start(); + + $output = $runner->getOutput(); + $outputStr = implode('', $output); + + $this->assertStringContainsString('debug msg', $outputStr); + } + + /** + * @test + */ + public function testVerbosityFlagsStrippedFromArgs() { + $runner = new Runner(); + $runner->reset(); + $runner->register(new VerbosityTestCommand()); + $runner->setInputs([]); + $runner->setArgsVector(['main.php', 'verb-test', '-v']); + $runner->start(); + + // Command should execute successfully (flag not passed as unknown arg) + $this->assertEquals(0, $runner->getLastCommandExitStatus()); + } + + /** + * @test + */ + public function testQuietFlagStrippedFromArgs() { + $runner = new Runner(); + $runner->reset(); + $runner->register(new VerbosityTestCommand()); + $runner->setInputs([]); + $runner->setArgsVector(['main.php', 'verb-test', '-q']); + $runner->start(); + + $this->assertEquals(0, $runner->getLastCommandExitStatus()); + } + + /** + * @test + */ + public function testCommandWithoutOwnerDefaultsToNormal() { + // Command without Runner uses NORMAL verbosity + $command = new VerbosityTestCommand(); + $output = $this->executeSingleCommand($command); + $outputStr = implode('', $output); + + $this->assertStringContainsString('info msg', $outputStr); + $this->assertStringNotContainsString('verbose msg', $outputStr); + } + + /** + * @test + */ + public function testInteractiveModeWithQuiet() { + $runner = new Runner(); + $runner->reset(); + $runner->register(new VerbosityTestCommand()); + $runner->setArgsVector(['main.php', '-i', '-q']); + $runner->setInputs(['verb-test', 'exit']); + $runner->start(); + + $output = $runner->getOutput(); + $outputStr = implode('', $output); + + $this->assertStringContainsString('error msg', $outputStr); + $this->assertStringNotContainsString('info msg', $outputStr); + } +}