clue/commander
Finally a sane way to register available commands and arguments and match your command line in PHP.
You want to build a command line interface (CLI) tool in PHP which accepts additional arguments and you now want to route these to individual functions? Then this library is for you!
This is also useful for interactive CLI tools or anywhere where you can break up a command line string into an array of command line arguments and you now want to execute individual functions depending on the arguments given.
Table of contents
Support us
We invest a lot of time developing, maintaining and updating our awesome open-source projects. You can help us sustain this high-quality of our work by becoming a sponsor on GitHub. Sponsors get numerous benefits in return, see our sponsoring page for details.
Let's take these projects to the next level together! 🚀
Quickstart example
The following example code demonstrates how this library can be used to build a very simple command line interface (CLI) tool that accepts command line arguments passed to this program:
$router = new Clue\Commander\Router();
$router->add('exit [<code:uint>]', function (array $args) {
exit(isset($args['code']) ? $args['code'] : 0);
});
$router->add('sleep <seconds:uint>', function (array $args) {
sleep($args['seconds']);
});
$router->add('echo <words>...', function (array $args) {
echo join(' ', $args['words']) . PHP_EOL;
});
$router->add('[--help | -h]', function () use ($router) {
echo 'Usage:' . PHP_EOL;
foreach ($router->getRoutes() as $route) {
echo ' ' .$route . PHP_EOL;
}
});
$router->execArgv();
See also the examples.
Usage
Router
The Router
is the main class in this package.
It is responsible for registering new Routes, matching the given args against these routes and then executing the registered route callback.
$router = new Router();
Advanced usage: The
Router
accepts an optionalTokenizer
instance as the first parameter to the constructor.
add()
The add(string $route, callable $handler): Route
method can be used to
register a new Route
with this Router.
It accepts a route expression to match and a route callback that will be executed when this route expression matches.
This is very similar to how common PHP (micro-)frameworks offer "HTTP routers" to route incoming HTTP requests to the corresponding "controller functions":
$route = $router->add($path, $fn);
The route expression uses a custom domain-specific language (DSL) which aims to be so simple that both consumers of this library (i.e. developers) and users of your resulting tools should be able to understand them.
Note that this is a left-associative grammar (LAG) and all tokens are greedy. This means that the tokens will be processed from left to right and each token will try to match as many of the input arguments as possible. This implies that certain route expressions make little sense, such as having an optional argument after an argument with ellipses. For more details, see below.
You can use an empty string like this to match when no arguments have been given:
$router->add('', function() {
echo 'No arguments given. Need help?' . PHP_EOL;
});
// matches: (empty string)
// does not match: hello (too many arguments)
You can use any number of static keywords like this:
$router->add('user list', function () {
echo 'Here are all our users…' . PHP_EOL;
});
// matches: user list
// does not match: user (missing required keyword)
// does not match: user list hello (too many arguments)
You can use alternative blocks to support any of the static keywords like this:
$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 parentheses, a b | c d
will be be interpreted as
(a b) | (c d)
by default.
Parentheses 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:
$router->add('user add <name>', function (array $args) {
assert(is_string($args['name']));
var_dump($args['name']);
});
// matches: user add clue
// does not match: user add (missing required argument)
// does not match: user add hello world (too many arguments)
// does not match: user add --test (argument looks like an option)
// matches: user add -- clue (value: clue)
// matches: user add -- --test (value: --test)
// matches: user add -- -nobody- (value: -nobody-)
// matches: user add -- -- (value: --)
Note that arguments that start with a dash (-
) are not simply accepted in the
user input, because they may be confused with (optional) options (see below).
If users wish to process arguments that start with a dash (-
), they either
have to use filters (see below) or may use a double dash separator (--
),
as everything after this separator will be processed as-is.
See also the last examples above that demonstrate this behavior.
You can use one the predefined filters to limit what values are accepted like this:
$router->add('user ban <id:int> <force:bool>', function (array $args) {
assert(is_int($args['id']));
assert(is_bool($args['force']));
});
// matches: user ban 10 true
// matches: user ban 10 0
// matches: user ban -10 yes
// matches: user ban -- -10 no
// does not match: user ban 10 (missing required argument)
// does not match: user ban hello true (invalid value does not validate)
Note that the filters also return the value casted to the correct data type.
Also note how using the double dash separator (--
) is optional when matching
a filtered value.
The following predefined filters are currently available:
int
accepts any positive or negative integer value, such as10
or-4
uint
accepts any positive (unsigned) integer value, such10
or0
float
accepts any positive or negative float value, such as1.5
or-2.3
ufloat
accepts any positive (unsigned) float value, such as1.5
or0
bool
accepts any boolean value, such asyes/true/1
orno/false/0
If you want to add a custom filter function, see also
Tokenizer
for advanced usage below.
You can mark arguments as optional by enclosing them in square brackets like this:
$router->add('user search [<query>]', function (array $args) {
assert(!isset($args['query']) || is_string($args['query']));
var_dump(isset($args['query']);
});
// matches: user search
// matches: user search clue
// does not match: user search hello world (too many arguments)
Note that square brackets can be added to pretty much any token in your route
expression, however they are most commonly used for arguments as above or for
optional options as below.
Optional tokens can appear anywhere in the route expression, but keep in mind
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:
$router->add('user delete <names>...', function (array $args) {
assert(is_array($args);
assert(count($args) > 0);
var_dump($args['names']);
});
// matches: user delete clue
// matches: user delete hello world
// does not match: user delete (missing required argument)
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 keep in mind that the tokens will be matched from the left to the right, so if the ellipse matches, it will consume all input arguments and not leave anything for following tokens. As a rule of thumb, make sure ellipse tokens are near the end of your route expression and you won't notice this subtle effect.
You can accept any number of optional arguments by appending ellipses within square brackets like this:
$router->add('user dump [<names>...]', function (array $args) {
if (isset($args['names'])) {
assert(is_array($args);
assert(count($args) > 0);
var_dump($args['names']);
} else {
var_dump('no names');
}
});
// matches: user dump
// matches: user dump clue
// matches: user dump hello world
The above does not require any arguments, it works with zero or more arguments.
You can add any number of optional short or long options like this:
$router->add('user list [--json] [-f]', function (array $args) {
assert(!isset($args['json']) || $args['json'] === false);
assert(!isset($args['f']) || $args['f'] === false);
});
// matches: user list
// matches: user list --json
// matches: user list -f
// matches: user list -f --json
// matches: user -f list
// matches: --json user list
As seen in the example, options in the $args
array can either be unset when
they have not been passed in the user input or set to false
when they have
been passed (which is in line with how other parsers such as getopt()
work).
Note that options are accepted anywhere in the user input argument, regardless
of where they have been defined.
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:
$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:
$router->add('[--sort[=<param>]] [-i=<start:int>] user list', function (array $args) {
assert(!isset($args['sort']) || $args['sort'] === false || is_string($args['sort']));
assert(!isset($args['i']) || is_int($args['i']));
});
// matches: user list
// matches: user list --sort
// matches: user list --sort=size
// matches: user list --sort size
// matches: user list -i=10
// matches: user list -i 10
// matches: user list -i10
// matches: user list -i=-10
// matches: user list -i -10
// matches: user list -i-10
// matches: user -i=10 list
// matches: --sort -- user list
// matches: --sort size user list
// matches: user list --sort -i=10
// does not match: user list -i (missing option value)
// does not match: user list -i --sort (missing option value)
// does not match: user list -i=a (invalid value does not validate)
// does not match: --sort user list (user will be interpreted as option value)
// does not match: user list --sort -2 (value looks like an option)
As seen in the example, option values in the $args
array will be given as
strings or their filtered and casted value if passed in the user input.
Both short and long options can accept values with the recommended equation
symbol syntax (-i=10
and --sort=size
respectively) in the user input.
Both short and long options can also accept values with the common space-separated
syntax (-i 10
and --sort size
respectively) in the user input.
Short options can also accept values with the common concatenated syntax
with no separator inbetween (-i10
) in the user input.
Note that it is highly recommended to always make sure any options that accept
values are near the left side of your route expression.
This is needed in order to make sure space-separated values are consumed as
option values instead of being misinterpreted as keywords or arguments.
You can limit the values for short and long options to a given preset like this:
$router->add('[--ask=(yes | no)] [-l[=0]] user purge', function (array $args) {
assert(!isset($args['ask']) || $args['sort'] === 'yes' || $args['sort'] === 'no');
assert(!isset($args['l']) || $args['l'] === '0');
});
// matches: user purge
// matches: user purge --ask=yes
// matches: user purge --ask=no
// matches: user purge -l
// matches: user purge -l=0
// matches: user purge -l 0
// matches: user purge -l0
// matches: user purge -l --ask=no
// does not match: user purge --ask (missing option value)
// does not match: user purge --ask=maybe (invalid option value)
// does not match: user purge -l4 (invalid option value)
As seen in the example, option values can be restricted to a given preset of
values by using any of the above tokens.
Technically, it's valid to use any of the above tokens to restrict the option
values.
In practice, this is mostly used for static keyword tokens or alternative groups
thereof.
It's recommended to always use parentheses for optional groups, however they're
not strictly required within options with optional values.
This also helps making it more obvious [--ask=(yes | no)]
would accept either
option value, while the (less useful) expression [--ask=yes | no]
would
accept either the option --ask=yes
or the static keyword no
.
remove()
The remove(Route $route): void
method can be used to remove the given
Route
object from the registered routes.
$route = $router->add('hello <name>', $fn);
$router->remove($route);
It will throw an UnderflowException
if the given route does not exist.
getRoutes()
The getRoutes(): Route[]
method can be used to return an array of all
registered Route
objects.
echo 'Usage help:' . PHP_EOL;
foreach ($router->getRoutes() as $route) {
echo $route . PHP_EOL;
}
This array will be empty if you have not added any routes yet.
execArgv()
The execArgv(array $argv = null): void
method can be used to
execute by matching the argv
against all registered routes and then exit.
You can explicitly pass in your $argv
or it will automatically use the
values from the $_SERVER
superglobal. The argv
is an array that will
always start with the calling program as the first element. We simply
ignore this first element and then process the remaining elements
according to the registered routes.
This is a convenience method that will match and execute a route and then exit the program without returning.
If no route could be found or if the route callback throws an Exception, it will print out an error message to STDERR and set an appropriate non-zero exit code.
Note that this is for convenience only and only useful for the most
simple of all programs. If you need more control, then consider using
the underlying handleArgv()
method and handle any error situations
yourself.
handleArgv()
The handleArgv(array $argv = null): mixed
method can be used to
execute by matching the argv
against all registered routes and then return.
You can explicitly pass in your $argv
or it will automatically use the
values from the $_SERVER
superglobal. The argv
is an array that will
always start with the calling program as the first element. We simply
ignore this first element and then process the remaining elements
according to the registered routes.
Unlike execArgv()
this method will try to execute the route callback
and then return whatever the route callback returned.
$router->add('hello <name>', function (array $args) {
return strlen($args[$name]);
});
$length = $router->handleArgv(array('program', 'hello', 'test'));
assert($length === 4);
If no route could be found, it will throw a NoRouteFoundException
.
// throws NoRouteFoundException
$router->handleArgv(array('program', 'invalid'));
If the route callback throws an Exception
, it will pass through this Exception
.
$router->add('hello <name>', function (array $args) {
if ($args['name'] === 'admin') {
throw new InvalidArgumentException();
}
return strlen($args['name']);
});
// throws InvalidArgumentException
$router->handleArgv(array('program', 'hello', 'admin'));
handleArgs()
The handleArgs(array $args): mixed
method can be used to
execute by matching the given args against all registered routes and then return.
Unlike handleArgv()
this method will use the complete $args
array
to match the registered routes (i.e. it will not ignore the first element).
This is particularly useful if you build this array yourself or if you
use an interactive command line interface (CLI) and ask your user to
supply the arguments.
$router->add('hello <name>', function (array $args) {
return strlen($args[$name]);
});
$length = $router->handleArgs(array('hello', 'test'));
assert($length === 4);
The arguments have to be given as an array of individual elements. If you only have a command line string that you want to split into an array of individual command line arguments, consider using clue/arguments.
$line = fgets(STDIN, 2048);
assert($line === 'hello "Christian Lück"');
$args = Clue\Arguments\split($line);
assert($args === array('hello', 'Christian Lück'));
$router->handleArgs($args);
If no route could be found, it will throw a NoRouteFoundException
.
// throws NoRouteFoundException
$router->handleArgs(array('invalid'));
If the route callback throws an Exception
, it will pass through this Exception
.
$router->add('hello <name>', function (array $args) {
if ($args['name'] === 'admin') {
throw new InvalidArgumentException();
}
return strlen($args['name']);
});
// throws InvalidArgumentException
$router->handleArgs(array('hello', 'admin'));
Route
The Route
represents a single registered route within the Router.
It holds the required route tokens to match and the route callback to execute if this route matches.
See Router
.
NoRouteFoundException
The NoRouteFoundException
will be raised by handleArgv()
or handleArgs()
if no matching route could be found.
It extends PHP's built-in RuntimeException
.
Tokenizer
The Tokenizer
class is responsible for parsing a route expression into a
valid token instance.
This class is mostly used internally and not something you have to worry about
in most cases.
If you need custom logic for your route expression, you may explicitly pass an
instance of your Tokenizer
to the constructor of the Router
:
$tokenizer = new Tokenizer();
$router = new Router($tokenizer);
addFilter()
The addFilter(string $name, callable $filter): void
method can be used to
add a custom filter function.
The filter name can then be used in argument or option expressions such as
add <name:lower>
or --search=<address:ip>
.
The filter function will be invoked with the filter value and MUST return a boolean success value if this filter accepts the given value. The filter value will be passed by reference, so it can be updated if the filtering was successful.
$tokenizer = new Tokenizer();
$tokenizer->addFilter('ip', function ($value) {
return filter_var($ip, FILTER_VALIDATE_IP);
});
$tokenizer->addFilter('lower', function (&$value) {
$value = strtolower($value);
return true;
});
$router = new Router($tokenizer);
$router->add('add <name:lower>', function ($args) { });
$router->add('--search=<address:ip>', function ($args) { });
Install
The recommended way to install this library is through Composer. New to Composer?
This project follows SemVer. This will install the latest supported version:
$ composer require clue/commander:^1.4
See also the CHANGELOG for details about version upgrades.
This project aims to run on any platform and thus does not require any PHP extensions and supports running on legacy PHP 5.3 through current PHP 8+ and HHVM. It's highly recommended to use PHP 7+ for this project.
Tests
To run the test suite, you first need to clone this repo and then install all dependencies through Composer:
$ composer install
To run the test suite, go to the project root and run:
$ php vendor/bin/phpunit
License
This project is released under the permissive MIT license.
Did you know that I offer custom development services and issuing invoices for sponsorships of releases and for contributions? Contact me (@clue) for details.
More
- If you want to build an interactive CLI tool, you may want to look into using clue/reactphp-stdio in order to react to commands from STDIN.
- If you build an interactive CLI tool that reads a command line from STDIN, you may want to use clue/arguments in order to split this string up into its individual arguments.