From b8457567cdda68b5c6294e37f386cebc58f4c5a8 Mon Sep 17 00:00:00 2001 From: Adriano Cataluddi Date: Sat, 10 May 2025 12:50:46 +0200 Subject: [PATCH 1/8] Configured PhpUnit cache file path for a less polluted root folder. Removed the "*" label for the default group. --- phpunit.xml.dist | 22 ++++++++++++---------- src/Helper/OutputHelper.php | 2 +- tests/Helper/OutputHelperTest.php | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index aa12943..cf769b1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,15 +1,17 @@ + backupGlobals="false" + backupStaticAttributes="false" + colors="true" + convertErrorsToExceptions="true" + convertNoticesToExceptions="true" + convertWarningsToExceptions="true" + processIsolation="true" + stopOnFailure="false" + bootstrap="tests/bootstrap.php" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" + cacheResultFile="./tests/data/cache/.phpunit.result.cache" +> ./src diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 9c68d62..13b5167 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -203,7 +203,7 @@ protected function showHelp(string $for, array $items, string $header = '', stri foreach (array_values($this->sortItems($items, $padLen, $for)) as $idx => $item) { $name = $this->getName($item); if ($for === 'Commands' && $lastGroup !== $group = $item->group()) { - $this->writer->help_group($group ?: '*', true); + $this->writer->help_group($group ?: '', true); $lastGroup = $group; } $desc = str_replace(["\r\n", "\n"], str_pad("\n", $padLen + $space + 3), $item->desc($withDefault)); diff --git a/tests/Helper/OutputHelperTest.php b/tests/Helper/OutputHelperTest.php index 29ce36a..f579f1d 100644 --- a/tests/Helper/OutputHelperTest.php +++ b/tests/Helper/OutputHelperTest.php @@ -95,7 +95,7 @@ public function test_show_commands() 'group', ' group:mkdir Make a folder', ' group:rm Remove file or folder', - '*', + '', ' mkdir Make a folder', ' rm Remove file or folder', '', From e531b665eeb1e59bb15f9d416387b431f19f21e9 Mon Sep 17 00:00:00 2001 From: Adriano Cataluddi Date: Sat, 10 May 2025 13:36:14 +0200 Subject: [PATCH 2/8] Commands in the default group gets printed first, without an empty grouping section. --- src/Helper/OutputHelper.php | 24 +++++++++++++++++------- tests/Helper/OutputHelperTest.php | 6 +++--- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 13b5167..c7c2b39 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -79,7 +79,7 @@ public function printTrace(Throwable $e): void $eClass = get_class($e); $this->writer->colors( - "{$eClass} {$e->getMessage()}" . + "$eClass {$e->getMessage()}" . '(' . t('thrown in') . " {$e->getFile()}:{$e->getLine()})" ); @@ -197,14 +197,15 @@ protected function showHelp(string $for, array $items, string $header = '', stri } $space = 4; - $group = $lastGroup = null; + $lastGroup = null; $withDefault = $for === 'Options' || $for === 'Arguments'; foreach (array_values($this->sortItems($items, $padLen, $for)) as $idx => $item) { $name = $this->getName($item); if ($for === 'Commands' && $lastGroup !== $group = $item->group()) { - $this->writer->help_group($group ?: '', true); $lastGroup = $group; + if ($group !== '') + $this->writer->help_group($group, true); } $desc = str_replace(["\r\n", "\n"], str_pad("\n", $padLen + $space + 3), $item->desc($withDefault)); @@ -254,6 +255,15 @@ public function showUsage(string $usage): self return $this; } + /** + * Shows an error message when a command is not found and suggests similar commands. + * Uses levenshtein distance to find commands that are similar to the attempted one. + * + * @param string $attempted The command name that was attempted to be executed + * @param array $available List of available command names + * + * @return OutputHelper For method chaining + */ public function showCommandNotFound(string $attempted, array $available): self { $closest = []; @@ -278,12 +288,12 @@ public function showCommandNotFound(string $attempted, array $available): self * Sort items by name. As a side effect sets max length of all names. * * @param Parameter[]|Command[] $items - * @param int $max + * @param int|null $max * @param string $for * * @return array */ - protected function sortItems(array $items, &$max = 0, string $for = ''): array + protected function sortItems(array $items, ?int &$max = 0, string $for = ''): array { $max = max(array_map(fn ($item) => strlen($this->getName($item)), $items)); @@ -292,8 +302,8 @@ protected function sortItems(array $items, &$max = 0, string $for = ''): array } uasort($items, static function ($a, $b) { - $aName = $a instanceof Groupable ? $a->group() . $a->name() : $a->name(); - $bName = $b instanceof Groupable ? $b->group() . $b->name() : $b->name(); + $aName = $a instanceof Groupable ? ($a->group() ?: '__') . $a->name() : $a->name(); + $bName = $b instanceof Groupable ? ($b->group() ?: '__') . $b->name() : $b->name(); return $aName <=> $bName; }); diff --git a/tests/Helper/OutputHelperTest.php b/tests/Helper/OutputHelperTest.php index f579f1d..be135e8 100644 --- a/tests/Helper/OutputHelperTest.php +++ b/tests/Helper/OutputHelperTest.php @@ -88,17 +88,17 @@ public function test_show_commands() new Command('group:mkdir', 'Make a folder'), ], 'Cmd Header', 'Cmd Footer'); + // If the default group exists, we expect visually to be rendered at the very top. $this->assertSame([ 'Cmd Header', '', 'Commands:', + ' mkdir Make a folder', + ' rm Remove file or folder', 'group', ' group:mkdir Make a folder', ' group:rm Remove file or folder', '', - ' mkdir Make a folder', - ' rm Remove file or folder', - '', 'Cmd Footer', ], $this->output()); } From 6f67a1b20a08d870870445eaf0c1a7e9fb178914 Mon Sep 17 00:00:00 2001 From: Adriano Cataluddi Date: Sat, 10 May 2025 13:48:54 +0200 Subject: [PATCH 3/8] Added comments to the OutputHelper's class. Moved protected methods after the public ones. --- src/Helper/OutputHelper.php | 155 +++++++++++++++++++++--------------- 1 file changed, 91 insertions(+), 64 deletions(-) diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index c7c2b39..d8d17c6 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -61,11 +61,18 @@ class OutputHelper { use InflectsString; + /** + * The output writer instance used to write formatted output. + * @var Writer + */ protected Writer $writer; - /** @var int Max width of command name */ protected int $maxCmdName = 0; + /** + * Class constructor. + * @param Writer|null $writer The output writer instance used to write formatted output. + */ public function __construct(?Writer $writer = null) { $this->writer = $writer ?? new Writer; @@ -107,6 +114,18 @@ public function printTrace(Throwable $e): void $this->writer->colors($traceStr); } + /** + * Converts an array of arguments into a string representation. + * + * Each array element is converted based on its type: + * - Scalar values (int, float, string, bool) are var_exported + * - Objects are converted using __toString() if available, otherwise class name is used + * - Arrays are recursively processed and wrapped in square brackets + * - Other types are converted to their type name + * + * @param array $args Array of arguments to be stringified + * @return string The comma-separated string representation of all arguments + */ public function stringifyArgs(array $args): string { $holder = []; @@ -118,23 +137,6 @@ public function stringifyArgs(array $args): string return implode(', ', $holder); } - protected function stringifyArg($arg): string - { - if (is_scalar($arg)) { - return var_export($arg, true); - } - - if (is_object($arg)) { - return method_exists($arg, '__toString') ? (string) $arg : get_class($arg); - } - - if (is_array($arg)) { - return '[' . $this->stringifyArgs($arg) . ']'; - } - - return gettype($arg); - } - /** * @param Argument[] $arguments * @param string $header @@ -179,50 +181,6 @@ public function showCommandsHelp(array $commands, string $header = '', string $f return $this; } - /** - * Show help with headers and footers. - */ - protected function showHelp(string $for, array $items, string $header = '', string $footer = ''): void - { - if ($header) { - $this->writer->help_header($header, true); - } - - $this->writer->eol()->help_category(t($for) . ':', true); - - if (empty($items)) { - $this->writer->help_text(' (n/a)', true); - - return; - } - - $space = 4; - $lastGroup = null; - - $withDefault = $for === 'Options' || $for === 'Arguments'; - foreach (array_values($this->sortItems($items, $padLen, $for)) as $idx => $item) { - $name = $this->getName($item); - if ($for === 'Commands' && $lastGroup !== $group = $item->group()) { - $lastGroup = $group; - if ($group !== '') - $this->writer->help_group($group, true); - } - $desc = str_replace(["\r\n", "\n"], str_pad("\n", $padLen + $space + 3), $item->desc($withDefault)); - - if ($idx % 2 == 0) { - $this->writer->help_item_even(' ' . str_pad($name, $padLen + $space)); - $this->writer->help_description_even($desc, true); - } else { - $this->writer->help_item_odd(' ' . str_pad($name, $padLen + $space)); - $this->writer->help_description_odd($desc, true); - } - } - - if ($footer) { - $this->writer->eol()->help_footer($footer, true); - } - } - /** * Show usage examples of a Command. * @@ -269,7 +227,7 @@ public function showCommandNotFound(string $attempted, array $available): self $closest = []; foreach ($available as $cmd) { $lev = levenshtein($attempted, $cmd); - if ($lev > 0 || $lev < 5) { + if ($lev > 0 && $lev < 5) { $closest[$cmd] = $lev; } } @@ -284,6 +242,73 @@ public function showCommandNotFound(string $attempted, array $available): self return $this; } + /** + * Show help with headers and footers. + */ + protected function showHelp(string $for, array $items, string $header = '', string $footer = ''): void + { + if ($header) { + $this->writer->help_header($header, true); + } + + $this->writer->eol()->help_category(t($for) . ':', true); + + if (empty($items)) { + $this->writer->help_text(' (n/a)', true); + + return; + } + + $space = 4; + $lastGroup = null; + + $withDefault = $for === 'Options' || $for === 'Arguments'; + foreach (array_values($this->sortItems($items, $padLen, $for)) as $idx => $item) { + $name = $this->getName($item); + if ($for === 'Commands' && $lastGroup !== $group = $item->group()) { + $lastGroup = $group; + if ($group !== '') + $this->writer->help_group($group, true); + } + $desc = str_replace(["\r\n", "\n"], str_pad("\n", $padLen + $space + 3), $item->desc($withDefault)); + + if ($idx % 2 == 0) { + $this->writer->help_item_even(' ' . str_pad($name, $padLen + $space)); + $this->writer->help_description_even($desc, true); + } else { + $this->writer->help_item_odd(' ' . str_pad($name, $padLen + $space)); + $this->writer->help_description_odd($desc, true); + } + } + + if ($footer) { + $this->writer->eol()->help_footer($footer, true); + } + } + + /** + * Converts the provided argument into a string representation. + * + * @param mixed $arg The argument to be converted into a string. This can be of any type. + * @return string A string representation of the provided argument. + */ + protected function stringifyArg(mixed $arg): string + { + if (is_scalar($arg)) { + return var_export($arg, true); + } + + if (is_object($arg)) { + return method_exists($arg, '__toString') ? (string)$arg : get_class($arg); + } + + if (is_array($arg)) { + return '[' . $this->stringifyArgs($arg) . ']'; + } + + return gettype($arg); + } + /** * Sort items by name. As a side effect sets max length of all names. * @@ -302,6 +327,8 @@ protected function sortItems(array $items, ?int &$max = 0, string $for = ''): ar } uasort($items, static function ($a, $b) { + // Items in the default group (where group() returns empty/falsy) are prefixed with '__' + // to ensure they appear at the top of the sorted list, whilst grouped items follow after $aName = $a instanceof Groupable ? ($a->group() ?: '__') . $a->name() : $a->name(); $bName = $b instanceof Groupable ? ($b->group() ?: '__') . $b->name() : $b->name(); @@ -318,7 +345,7 @@ protected function sortItems(array $items, ?int &$max = 0, string $for = ''): ar * * @return string */ - protected function getName($item): string + protected function getName(Parameter|Command $item): string { $name = $item->name(); From 6f0dc8b7919993ad1bbef724d69601e0785c1480 Mon Sep 17 00:00:00 2001 From: Adriano Cataluddi Date: Sat, 10 May 2025 16:14:36 +0200 Subject: [PATCH 4/8] Removed in-line if statement - Fixed #125. --- src/Helper/OutputHelper.php | 3 ++- tests/Helper/OutputHelperTest.php | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index d8d17c6..821e738 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -267,8 +267,9 @@ protected function showHelp(string $for, array $items, string $header = '', stri $name = $this->getName($item); if ($for === 'Commands' && $lastGroup !== $group = $item->group()) { $lastGroup = $group; - if ($group !== '') + if ($group !== '') { $this->writer->help_group($group, true); + } } $desc = str_replace(["\r\n", "\n"], str_pad("\n", $padLen + $space + 3), $item->desc($withDefault)); diff --git a/tests/Helper/OutputHelperTest.php b/tests/Helper/OutputHelperTest.php index be135e8..2706fbd 100644 --- a/tests/Helper/OutputHelperTest.php +++ b/tests/Helper/OutputHelperTest.php @@ -28,7 +28,7 @@ class OutputHelperTest extends TestCase { - protected static $ou = __DIR__ . '/output'; + protected static string $ou = __DIR__ . '/output'; public function setUp(): void { @@ -150,7 +150,7 @@ public function test_stringify() $this->assertSame("[NULL, 'string', 10000, 12.345, DateTime]", $str); } - public function newHelper() + public function newHelper(): OutputHelper { return new OutputHelper(new Writer(static::$ou, new class extends Color { protected string $format = ':txt:'; From 7e657fd9730f7e3b2e35fd18cb5f000b92c172cf Mon Sep 17 00:00:00 2001 From: Adriano Cataluddi Date: Sat, 10 May 2025 16:21:07 +0200 Subject: [PATCH 5/8] Minor code formatting to comply with StyleCI. --- src/Helper/OutputHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 821e738..d8e9d0d 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -259,7 +259,7 @@ protected function showHelp(string $for, array $items, string $header = '', stri return; } - $space = 4; + $space = 4; $lastGroup = null; $withDefault = $for === 'Options' || $for === 'Arguments'; From 845efafb2c8563f50a003e6452005a6b6b027195 Mon Sep 17 00:00:00 2001 From: Adriano Cataluddi Date: Sat, 10 May 2025 16:23:16 +0200 Subject: [PATCH 6/8] Minor code formatting to comply with StyleCI. --- src/Helper/OutputHelper.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index d8e9d0d..34d26be 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -63,6 +63,7 @@ class OutputHelper /** * The output writer instance used to write formatted output. + * * @var Writer */ protected Writer $writer; @@ -71,6 +72,7 @@ class OutputHelper /** * Class constructor. + * * @param Writer|null $writer The output writer instance used to write formatted output. */ public function __construct(?Writer $writer = null) @@ -218,7 +220,7 @@ public function showUsage(string $usage): self * Uses levenshtein distance to find commands that are similar to the attempted one. * * @param string $attempted The command name that was attempted to be executed - * @param array $available List of available command names + * @param array $available List of available command names * * @return OutputHelper For method chaining */ @@ -291,6 +293,7 @@ protected function showHelp(string $for, array $items, string $header = '', stri * Converts the provided argument into a string representation. * * @param mixed $arg The argument to be converted into a string. This can be of any type. + * * @return string A string representation of the provided argument. */ protected function stringifyArg(mixed $arg): string @@ -300,7 +303,7 @@ protected function stringifyArg(mixed $arg): string } if (is_object($arg)) { - return method_exists($arg, '__toString') ? (string)$arg : get_class($arg); + return method_exists($arg, '__toString') ? (string) $arg : get_class($arg); } if (is_array($arg)) { From 4fc444f714ac2356c7d0b59cfac5e33aa8fd9810 Mon Sep 17 00:00:00 2001 From: Adriano Cataluddi Date: Sat, 10 May 2025 16:24:12 +0200 Subject: [PATCH 7/8] Minor code formatting to comply with StyleCI. --- src/Helper/OutputHelper.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 34d26be..0f70d2f 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -126,6 +126,7 @@ public function printTrace(Throwable $e): void * - Other types are converted to their type name * * @param array $args Array of arguments to be stringified + * * @return string The comma-separated string representation of all arguments */ public function stringifyArgs(array $args): string From 881c48161b8e11dd6e23b1b1137c4e4788e2f26c Mon Sep 17 00:00:00 2001 From: Adriano Cataluddi Date: Sun, 11 May 2025 10:43:12 +0200 Subject: [PATCH 8/8] Restored original method positions to facilitate easier change comparisons #125. --- src/Helper/OutputHelper.php | 144 ++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 70 deletions(-) diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 0f70d2f..75eb6fc 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -67,7 +67,11 @@ class OutputHelper * @var Writer */ protected Writer $writer; - /** @var int Max width of command name */ + /** + * Max width of command name. + * + * @var int + */ protected int $maxCmdName = 0; /** @@ -140,6 +144,30 @@ public function stringifyArgs(array $args): string return implode(', ', $holder); } + /** + * Converts the provided argument into a string representation. + * + * @param mixed $arg The argument to be converted into a string. This can be of any type. + * + * @return string A string representation of the provided argument. + */ + protected function stringifyArg(mixed $arg): string + { + if (is_scalar($arg)) { + return var_export($arg, true); + } + + if (is_object($arg)) { + return method_exists($arg, '__toString') ? (string) $arg : get_class($arg); + } + + if (is_array($arg)) { + return '[' . $this->stringifyArgs($arg) . ']'; + } + + return gettype($arg); + } + /** * @param Argument[] $arguments * @param string $header @@ -184,6 +212,51 @@ public function showCommandsHelp(array $commands, string $header = '', string $f return $this; } + /** + * Show help with headers and footers. + */ + protected function showHelp(string $for, array $items, string $header = '', string $footer = ''): void + { + if ($header) { + $this->writer->help_header($header, true); + } + + $this->writer->eol()->help_category(t($for) . ':', true); + + if (empty($items)) { + $this->writer->help_text(' (n/a)', true); + + return; + } + + $space = 4; + $lastGroup = null; + + $withDefault = $for === 'Options' || $for === 'Arguments'; + foreach (array_values($this->sortItems($items, $padLen, $for)) as $idx => $item) { + $name = $this->getName($item); + if ($for === 'Commands' && $lastGroup !== $group = $item->group()) { + $lastGroup = $group; + if ($group !== '') { + $this->writer->help_group($group, true); + } + } + $desc = str_replace(["\r\n", "\n"], str_pad("\n", $padLen + $space + 3), $item->desc($withDefault)); + + if ($idx % 2 == 0) { + $this->writer->help_item_even(' ' . str_pad($name, $padLen + $space)); + $this->writer->help_description_even($desc, true); + } else { + $this->writer->help_item_odd(' ' . str_pad($name, $padLen + $space)); + $this->writer->help_description_odd($desc, true); + } + } + + if ($footer) { + $this->writer->eol()->help_footer($footer, true); + } + } + /** * Show usage examples of a Command. * @@ -245,75 +318,6 @@ public function showCommandNotFound(string $attempted, array $available): self return $this; } - /** - * Show help with headers and footers. - */ - protected function showHelp(string $for, array $items, string $header = '', string $footer = ''): void - { - if ($header) { - $this->writer->help_header($header, true); - } - - $this->writer->eol()->help_category(t($for) . ':', true); - - if (empty($items)) { - $this->writer->help_text(' (n/a)', true); - - return; - } - - $space = 4; - $lastGroup = null; - - $withDefault = $for === 'Options' || $for === 'Arguments'; - foreach (array_values($this->sortItems($items, $padLen, $for)) as $idx => $item) { - $name = $this->getName($item); - if ($for === 'Commands' && $lastGroup !== $group = $item->group()) { - $lastGroup = $group; - if ($group !== '') { - $this->writer->help_group($group, true); - } - } - $desc = str_replace(["\r\n", "\n"], str_pad("\n", $padLen + $space + 3), $item->desc($withDefault)); - - if ($idx % 2 == 0) { - $this->writer->help_item_even(' ' . str_pad($name, $padLen + $space)); - $this->writer->help_description_even($desc, true); - } else { - $this->writer->help_item_odd(' ' . str_pad($name, $padLen + $space)); - $this->writer->help_description_odd($desc, true); - } - } - - if ($footer) { - $this->writer->eol()->help_footer($footer, true); - } - } - - /** - * Converts the provided argument into a string representation. - * - * @param mixed $arg The argument to be converted into a string. This can be of any type. - * - * @return string A string representation of the provided argument. - */ - protected function stringifyArg(mixed $arg): string - { - if (is_scalar($arg)) { - return var_export($arg, true); - } - - if (is_object($arg)) { - return method_exists($arg, '__toString') ? (string) $arg : get_class($arg); - } - - if (is_array($arg)) { - return '[' . $this->stringifyArgs($arg) . ']'; - } - - return gettype($arg); - } - /** * Sort items by name. As a side effect sets max length of all names. *