From 06d29368e633d179a127f60370883ff86adda3bf Mon Sep 17 00:00:00 2001 From: Everton da Rosa Date: Fri, 25 Oct 2024 11:28:28 -0300 Subject: [PATCH] feat: Adds argument suggestion support for unknown arguments When an undefined prefixed argument is provided, the current default behavior is to ignore this unknown argument. Some command-line tools (Composer is an example) suggest alternative arguments based on a similarity between the unknown argument and those defined in the script. This modification adds this functionality to CLImate without breaking compatibility with previous versions and without this functionality being mandatory. The `League\CLImate\Argument\Manager::getUnknowPrefixedArgumentsAndSuggestions()` method returns an array with the unknown arguments as keys and the best argument suggestion as values, based on the PHP function [similar_text](https://www.php.net/manual/en/function.similar-text.php). With this result, the developer can easily implement a message to the user informing about the unknown argument and offering alternatives, as in this example: arguments->add([ 'user' => [ 'longPrefix' => 'user', ], 'password' => [ 'longPrefix' => 'password', ], 'flag' => [ 'longPrefix' => 'flag', 'noValue' => true, ], ]); $climate->arguments->parse(); $suggestions = $climate->arguments->getUnknowPrefixedArgumentsAndSuggestions(); if(count($suggestions) > 0){ $climate->error('Arguments not defined:'); foreach ($suggestions as $arg => $suggest){ if($suggest !== ''){ $climate->info("\"$arg\" is not defined. Did you mean these? $suggest"); } } } /* * Run: * * ~$ php .\test.php --user=baz --pass=123 --fag --xyz * * Return: * * Arguments not defined: * "pass" is not defined. Did you mean these? password * "fag" is not defined. Did you mean these? flag * */ --- src/Argument/Manager.php | 20 ++++++ src/Argument/Parser.php | 127 +++++++++++++++++++++++++++++++-- tests/Argument/ManagerTest.php | 32 +++++++++ 3 files changed, 172 insertions(+), 7 deletions(-) diff --git a/src/Argument/Manager.php b/src/Argument/Manager.php index a18410b2..4f34a765 100644 --- a/src/Argument/Manager.php +++ b/src/Argument/Manager.php @@ -258,4 +258,24 @@ public function trailingArray() { return $this->parser->trailingArray(); } + + /** + * Returns the list of unknown prefixed arguments and their suggestions. + * + * @return array The list of unknown prefixed arguments and their suggestions. + */ + public function getUnknowPrefixedArgumentsAndSuggestions() + { + return $this->parser->getUnknowPrefixedArgumentsAndSuggestions(); + } + + /** + * Sets the minimum similarity percentage for finding suggestions. + * + * @param float $percentage The minimum similarity percentage to set. + */ + public function setMinimumSimilarityPercentage(float $percentage) + { + $this->parser->setMinimumSimilarityPercentage($percentage); + } } diff --git a/src/Argument/Parser.php b/src/Argument/Parser.php index 1badc638..a4f62d98 100644 --- a/src/Argument/Parser.php +++ b/src/Argument/Parser.php @@ -1,11 +1,11 @@ summary = new Summary(); @@ -61,6 +76,10 @@ public function parse(array $argv = null) $unParsedArguments = $this->prefixedArguments($cliArguments); + // Searches for unknown prefixed arguments and finds a suggestion + // within the list of valid arguments. + $this->unknowPrefixedArguments($unParsedArguments); + $this->nonPrefixedArguments($unParsedArguments); // After parsing find out which arguments were required but not @@ -69,9 +88,9 @@ public function parse(array $argv = null) if (count($missingArguments) > 0) { throw new InvalidArgumentException( - 'The following arguments are required: ' - . $this->summary->short($missingArguments) . '.' - ); + 'The following arguments are required: ' + . $this->summary->short($missingArguments) . '.' + ); } } @@ -302,8 +321,102 @@ protected function getCommandAndArguments(array $argv = null) } $arguments = $argv; - $command = array_shift($arguments); + $command = array_shift($arguments); return compact('arguments', 'command'); } + + /** + * Processes unknown prefixed arguments and sets suggestions if no matching + * prefix is found. + * + * @param array $unParsedArguments The array of unparsed arguments to + * process. + */ + protected function unknowPrefixedArguments(array $unParsedArguments) + { + foreach ($unParsedArguments as $arg) { + $unknowArgumentName = $this->getUnknowArgumentName($arg); + if (!$this->findPrefixedArgument($unknowArgumentName)) { + if (is_null($unknowArgumentName)) { + continue; + } + $suggestion = $this->findSuggestionsForUnknowPrefixedArguments( + $unknowArgumentName, + $this->filter->withPrefix() + ); + $this->setSuggestion($unknowArgumentName, $suggestion); + } + } + } + + /** + * Sets the suggestion for an unknown argument name. + * + * @param string $unknowArgName The name of the unknown argument. + * @param string $suggestion The suggestion for the unknown argument. + */ + protected function setSuggestion(string $unknowArgName, string $suggestion) + { + $this->unknowPrefixedArguments[$unknowArgName] = $suggestion; + } + + /** + * Extracts the unknown argument name from a given argument string. + * + * @param string $arg The argument string to process. + * @return string|null The extracted unknown argument name or null if not + * found. + */ + protected function getUnknowArgumentName(string $arg) + { + if (preg_match('/^[-]{1,2}([^-]+?)(?:=|$)/', $arg, $matches)) { + return $matches[1]; + } + return null; + } + + /** + * Finds the most similar known argument for an unknown prefixed argument. + * + * @param string $argName The name of the unknown argument to find + * suggestions for. + * @param array $argList The list of known arguments to compare against. + * @return string The most similar known argument name. + */ + protected function findSuggestionsForUnknowPrefixedArguments( + string $argName, + array $argList + ) { + $mostSimilar = ''; + $greatestSimilarity = $this->minimumSimilarityPercentage * 100; + foreach ($argList as $arg) { + similar_text($argName, $arg->name(), $percent); + if ($percent > $greatestSimilarity) { + $greatestSimilarity = $percent; + $mostSimilar = $arg->name(); + } + } + return $mostSimilar; + } + + /** + * Returns the list of unknown prefixed arguments and their suggestions. + * + * @return array The list of unknown prefixed arguments and their suggestions. + */ + public function getUnknowPrefixedArgumentsAndSuggestions() + { + return $this->unknowPrefixedArguments; + } + + /** + * Sets the minimum similarity percentage for finding suggestions. + * + * @param float $percentage The minimum similarity percentage to set. + */ + public function setMinimumSimilarityPercentage(float $percentage) + { + $this->minimumSimilarityPercentage = $percentage; + } } diff --git a/tests/Argument/ManagerTest.php b/tests/Argument/ManagerTest.php index 2a66c186..f5f566c9 100644 --- a/tests/Argument/ManagerTest.php +++ b/tests/Argument/ManagerTest.php @@ -70,4 +70,36 @@ public function testItStoresTrailingInArray() $this->assertEquals('test trailing with spaces', $this->manager->trailing()); $this->assertEquals(['test', 'trailing with spaces'], $this->manager->trailingArray()); } + + public function testItSuggestAlternativesToUnknowArguments() + { + $this->manager->add([ + 'user' => [ + 'longPrefix' => 'user', + ], + 'password' => [ + 'longPrefix' => 'password', + ], + 'flag' => [ + 'longPrefix' => 'flag', + 'noValue' => true, + ], + ]); + + $argv = [ + 'test-script', + '--user=baz', + '--pass=123', + '--fag', + '--xyz', + ]; + + $this->manager->parse($argv); + $processed = $this->manager->getUnknowPrefixedArgumentsAndSuggestions(); + + $this->assertCount(3, $processed); + $this->assertEquals('password', $processed['pass']); + $this->assertEquals('flag', $processed['fag']); + $this->assertEquals('', $processed['xyz']); + } }