Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support alternative groups and optional parentheses #15

Merged
merged 5 commits into from
Nov 6, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ $router->add('sleep <seconds>', function (array $args) {
$router->add('echo <words>...', function (array $args) {
echo join(' ', $args['words']) . PHP_EOL;
});
$router->add('[--help]', function () use ($router) {
$router->add('[--help | -h]', function () use ($router) {
echo 'Usage:' . PHP_EOL;
foreach ($router->getRoutes() as $route) {
echo ' ' .$route . PHP_EOL;
Expand Down Expand Up @@ -117,6 +117,30 @@ $router->add('user list', function () {
// does not match: user list hello (too many arguments)
```

You can use alternative blocks to support any of the static keywords like this:

```php
$router->add('user (list | listing | ls)', function () {
echo 'Here are all our users…' . PHP_EOL;
});
// matches: user list
// matches: user listing
// matches: user ls
// does not match: user (missing required keyword)
// does not match: user list hello (too many arguments)
```

Note that alternative blocks can be added to pretty much any token in your route
expression.
Note that alternative blocks do not require parentheses and the alternative mark
(`|`) always works at the current block level, which may not always be obvious.
Unless you add some parenthesis, `a b | c d` will be be interpreted as
`(a b) | (c d)` by default.
Parenthesis can be used to interpret this as `a (b | c) d` instead.
In particular, you can also combine alternative blocks with optional blocks
(see below) in order to optionally accept only one of the alternatives, but not
multiple.

You can use any number of placeholders to mark required arguments like this:

```php
Expand Down Expand Up @@ -162,6 +186,9 @@ that the tokens will be matched from left to right, so if the optional token
matches, then the remainder will be processed by the following tokens.
As a rule of thumb, make sure optional tokens are near the end of your route
expressions and you won't notice this subtle effect.
Optional blocks accept alternative groups, so that `[a | b]` is actually
equivalent to the longer form `[(a | b)]`.
In particular, this is often used for alternative options as below.

You can accept any number of arguments by appending ellipses like this:

Expand All @@ -176,8 +203,8 @@ $router->add('user delete <names>...', function (array $args) {
// does not match: user delete (missing required argument)
```

Note that trailing ellipses can be added to pretty much any token in your route
expression, however they are most commonly used for arguments as above.
Note that trailing ellipses can be added to any argument, word or option token
in your route expression. They are most commonly used for arguments as above.
The above requires at least one argument, see the following if you want this
to be completely optional.
Technically, the ellipse tokens can appear anywhere in the route expression, but
Expand Down Expand Up @@ -230,6 +257,23 @@ Note that the square brackets are in the route expression are required to mark
this optional as optional, you can also omit these square brackets if you really
want a required option.

You can combine short and long options in an alternative block like this:

```php
$router->add('user setup [--help | -h]', function (array $args) {
assert(!isset($args['help']) || $args['help'] === false);
assert(!isset($args['h']) || $args['h'] === false);
assert(!isset($args['help'], $args['h']);
});
// matches: user setup
// matches: user setup --help
// matches: user setup -h
// does not match: user setup --help -h (only accept eithers, not both)
```

As seen in the example, this optionally accepts either the short or the long
option anywhere in the user input, but never both at the same time.

You can optionally accept or require values for short and long options like this:

```php
Expand Down
2 changes: 1 addition & 1 deletion examples/01-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
$router->add('echo <words>...', function (array $args) {
echo join(' ', $args['words']) . PHP_EOL;
});
$router->add('[--help]', function () use ($router) {
$router->add('[--help | -h]', function () use ($router) {
echo 'Usage:' . PHP_EOL;
foreach ($router->getRoutes() as $route) {
echo ' ' .$route . PHP_EOL;
Expand Down
2 changes: 1 addition & 1 deletion examples/02-errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
$router->add('echo <words>...', function (array $args) {
echo join(' ', $args['words']) . PHP_EOL;
});
$router->add('[--help]', function () use ($router) {
$router->add('[--help | -h]', function () use ($router) {
echo 'Usage:' . PHP_EOL;
foreach ($router->getRoutes() as $route) {
echo ' ' .$route . PHP_EOL;
Expand Down
49 changes: 49 additions & 0 deletions src/Tokens/AlternativeToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace Clue\Commander\Tokens;

use InvalidArgumentException;

class AlternativeToken implements TokenInterface
{
private $tokens = array();

public function __construct(array $tokens)
{
foreach ($tokens as $token) {
if ($token instanceof OptionalToken) {
throw new InvalidArgumentException('Alternative group must not contain optional tokens');
} elseif (!$token instanceof TokenInterface) {
throw new InvalidArgumentException('Alternative group must only contain valid tokens');
} elseif ($token instanceof self) {
// merge nested alternative group
foreach ($token->tokens as $token) {
$this->tokens []= $token;
}
} else {
// append any valid alternative token
$this->tokens []= $token;
}
}

if (count($this->tokens) < 2) {
throw new InvalidArgumentException('Alternative group must contain at least 2 tokens');
}
}

public function matches(array &$input, array &$output)
{
foreach ($this->tokens as $token) {
if ($token->matches($input, $output)) {
return true;
}
}

return false;
}

public function __toString()
{
return implode(' | ', $this->tokens);
}
}
6 changes: 4 additions & 2 deletions src/Tokens/EllipseToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@

class EllipseToken implements TokenInterface
{
private $token;

public function __construct(TokenInterface $token)
{
if ($token instanceof self) {
throw new InvalidArgumentException('Nested ellipse block is superfluous');
if (!$token instanceof ArgumentToken && !$token instanceof OptionToken && !$token instanceof WordToken) {
throw new InvalidArgumentException('Ellipse only for individual words/arguments/options');
}

$this->token = $token;
Expand Down
25 changes: 17 additions & 8 deletions src/Tokens/SentenceToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,26 @@

class SentenceToken implements TokenInterface
{
private $tokens;
private $tokens = array();

public function __construct(array $tokens)
{
if (count($tokens) < 2) {
throw new InvalidArgumentException('Sentence must contain at least 2 tokens');
}

foreach ($tokens as $token) {
if (!$token instanceof TokenInterface) {
throw new InvalidArgumentException('Sentence must only contain valid tokens');
} elseif ($token instanceof self) {
throw new InvalidArgumentException('Sentence must not contain sub-sentence token');
// merge any tokens from sub-sentences
foreach ($token->tokens as $token) {
$this->tokens []= $token;
}
} else {
$this->tokens []= $token;
}
}

$this->tokens = $tokens;
if (count($this->tokens) < 2) {
throw new InvalidArgumentException('Sentence must contain at least 2 tokens');
}
}

public function matches(array &$input, array &$output)
Expand All @@ -43,6 +46,12 @@ public function matches(array &$input, array &$output)

public function __toString()
{
return implode(' ', $this->tokens);
return implode(' ', array_map(function (TokenInterface $token) {
// alternative token should be surrounded in parentheses
if ($token instanceof AlternativeToken) {
return '(' . $token . ')';
}
return (string)$token;
}, $this->tokens));
}
}
69 changes: 63 additions & 6 deletions src/Tokens/Tokenizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class Tokenizer
public function createToken($input)
{
$i = 0;
$token = $this->readSentenceOrSingle($input, $i);
$token = $this->readAlternativeSentenceOrSingle($input, $i);

if (isset($input[$i])) {
throw new \InvalidArgumentException('Invalid root token, expression has superfluous contents');
Expand All @@ -47,8 +47,8 @@ private function readSentenceOrSingle($input, &$i)
$previous = $i;
$this->consumeOptionalWhitespace($input, $i);

// end of input reached
if (!isset($input[$i]) || $input[$i] === ']') {
// end of input reached or end token found
if (!isset($input[$i]) || strpos('])|', $input[$i]) !== false) {
break;
}

Expand Down Expand Up @@ -81,6 +81,8 @@ private function readToken($input, &$i)
return $this->readArgument($input, $i);
} elseif ($input[$i] === '[') {
return $this->readOptionalBlock($input, $i);
} elseif ($input[$i] === '(') {
return $this->readParenthesesBlock($input, $i);
} else {
return $this->readWord($input, $i);
}
Expand Down Expand Up @@ -116,10 +118,10 @@ private function readOptionalBlock($input, &$i)
{
// advance to contents of optional block and read inner sentence
$i++;
$token = $this->readSentenceOrSingle($input, $i);
$token = $this->readAlternativeSentenceOrSingle($input, $i);

// above should stop at end token, otherwise syntax error
if (!isset($input[$i])) {
if (!isset($input[$i]) || $input[$i] !== ']') {
throw new InvalidArgumentException('Missing end of optional block');
}

Expand All @@ -129,10 +131,65 @@ private function readOptionalBlock($input, &$i)
return new OptionalToken($token);
}

private function readParenthesesBlock($input, &$i)
{
// advance to contents of parentheses block and read inner sentence
$i++;
$token = $this->readAlternativeSentenceOrSingle($input, $i);

// above should stop and end token, otherwise syntax error
if (!isset($input[$i]) || $input[$i] !== ')') {
throw new InvalidArgumentException('Missing end of alternative block');
}

// skip end token
$i++;

return $token;
}

/**
* reads a complete sentence token until end of group
*
* An "alternative sentence" may contain the following tokens:
* - an alternative group (which may consist of individual sentences separated by `|`)
* - a sentence (which may consist of multiple tokens)
* - a single token
*
* @param string $input
* @param int $i
* @throws InvalidArgumentException
* @return TokenInterface
*/
private function readAlternativeSentenceOrSingle($input, &$i)
{
$tokens = array();

while (true) {
$tokens []= $this->readSentenceOrSingle($input, $i);

// end of input reached or end token found
if (!isset($input[$i]) || strpos('])', $input[$i]) !== false) {
break;
}

// cursor now at alternative symbol (all other symbols are already handled)
// skip alternative mark and continue with next alternative
$i++;
}

// return a single token as-is
if (isset($tokens[0]) && !isset($tokens[1])) {
return $tokens[0];
}

return new AlternativeToken($tokens);
}

private function readWord($input, &$i)
{
// static word token, buffer until next whitespace or closing square bracket
preg_match('/(?:[^\[\]\s]+|\[[^\]]+\])+/', $input, $matches, 0, $i);
preg_match('/(?:[^\[\]\(\)\|\s]+|\[[^\]]+\])+/', $input, $matches, 0, $i);

$word = $matches[0];
$i += strlen($word);
Expand Down
46 changes: 46 additions & 0 deletions tests/RouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,37 @@ public static function provideMatchingRoutes()
array()
),

'alternative group with words' => array(
'(a | b | c)',
array('b'),
array()
),
'alternative group with long and without short options' => array(
'(--help | -h)',
array('--help'),
array('help' => false)
),
'alternative group without long and with short options' => array(
'(--help | -h)',
array('-h'),
array('h' => false)
),
'optional alternative group with long and without short options' => array(
'[--help | -h]',
array('--help'),
array('help' => false)
),
'optional alternative group without long and with short options' => array(
'[--help | -h]',
array('-h'),
array('h' => false)
),
'optional alternative group without long and without short options' => array(
'[--help | -h]',
array(),
array()
),

'word with required long option with required value' => array(
'hello --name=<yes>',
array('hello', '--name=demo'),
Expand Down Expand Up @@ -232,6 +263,16 @@ public function provideNonMatchingRoutes()
'hello [user] user',
array('hello', 'user')
),

'alternative group with wrong word' => array(
'(a | b | c)',
array('d')
),
'alternative group without any option' => array(
'(--help | -h)',
array()
),

'without long option' => array(
'hello [--test]',
array('hello', 'test')
Expand Down Expand Up @@ -282,6 +323,11 @@ public function provideNonMatchingRoutes()
'test -i=<value>',
array('test', '-i', '-n')
),

'alternative options do not accept both' => array(
'test [--help | -h]',
array('test', '--help', '-h')
),
);
}

Expand Down
Loading