Skip to content

Commit

Permalink
feat: Adds argument suggestion support for unknown arguments
Browse files Browse the repository at this point in the history
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:

<?php
require_once 'vendor/autoload.php';

$climate = new \League\CLImate\CLImate();

$climate->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
 *
 */
  • Loading branch information
everton3x committed Oct 25, 2024
1 parent a785a3a commit 06d2936
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 7 deletions.
20 changes: 20 additions & 0 deletions src/Argument/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
127 changes: 120 additions & 7 deletions src/Argument/Parser.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<?php

namespace League\CLImate\Argument;

use League\CLImate\Exceptions\InvalidArgumentException;

class Parser
{

/**
* Filter class to find various types of arguments
*
Expand All @@ -19,11 +19,26 @@ class Parser
* @var \League\CLImate\Argument\Summary $summary
*/
protected $summary;

protected $trailing;

protected $trailingArray;

/**
* List of unknown arguments and best argument suggestion.
*
* The key corresponds to the unknown argument and the value to the
* argument suggestion, if any.
*
* @var array
*/
protected $unknowPrefixedArguments = [];

/**
* Minimum similarity percentage to detect similar arguments.
*
* @var float
*/
protected $minimumSimilarityPercentage = 0.6;

public function __construct()
{
$this->summary = new Summary();
Expand Down Expand Up @@ -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
Expand All @@ -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) . '.'
);
}
}

Expand Down Expand Up @@ -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;
}
}
32 changes: 32 additions & 0 deletions tests/Argument/ManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}
}

0 comments on commit 06d2936

Please sign in to comment.