├── .github └── FUNDING.yml ├── src ├── NoRouteFoundException.php ├── Tokens │ ├── OptionalToken.php │ ├── WordToken.php │ ├── TokenInterface.php │ ├── AlternativeToken.php │ ├── EllipseToken.php │ ├── SentenceToken.php │ ├── ArgumentToken.php │ ├── OptionToken.php │ └── Tokenizer.php ├── Route.php └── Router.php ├── composer.json ├── LICENSE ├── CHANGELOG.md └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: clue 2 | custom: https://clue.engineering/support 3 | -------------------------------------------------------------------------------- /src/NoRouteFoundException.php: -------------------------------------------------------------------------------- 1 | token = $token; 18 | } 19 | 20 | public function matches(array &$input, array &$output) 21 | { 22 | // try greedy match for sub-token or succeed anyway 23 | $this->token->matches($input, $output); 24 | 25 | return true; 26 | } 27 | 28 | public function __toString() 29 | { 30 | return '[' . $this->token . ']'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clue/commander", 3 | "description": "Finally a sane way to register available commands and arguments and match your command line in PHP.", 4 | "keywords": ["arguments", "command", "router", "command line", "argv", "args", "getopt", "parse"], 5 | "homepage": "https://github.com/clue/commander", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Christian Lück", 10 | "email": "christian@clue.engineering" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { "Clue\\Commander\\": "src/" } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { "Clue\\Tests\\Commander\\": "tests/" } 18 | }, 19 | "require": { 20 | "php": ">=5.3" 21 | }, 22 | "require-dev": { 23 | "clue/arguments": "^1.0", 24 | "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Tokens/WordToken.php: -------------------------------------------------------------------------------- 1 | word = $word; 17 | } 18 | 19 | public function matches(array &$input, array &$output) 20 | { 21 | foreach ($input as $key => $value) { 22 | if ($value === $this->word) { 23 | unset($input[$key]); 24 | return true; 25 | } elseif ($value === '' || $value[0] !== '-') { 26 | // any other word/argument (non-option) found => fail 27 | break; 28 | } 29 | } 30 | return false; 31 | } 32 | 33 | public function __toString() 34 | { 35 | return $this->word; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Tokens/TokenInterface.php: -------------------------------------------------------------------------------- 1 | tokens as $token) { 21 | $this->tokens []= $token; 22 | } 23 | } else { 24 | // append any valid alternative token 25 | $this->tokens []= $token; 26 | } 27 | } 28 | 29 | if (count($this->tokens) < 2) { 30 | throw new InvalidArgumentException('Alternative group must contain at least 2 tokens'); 31 | } 32 | } 33 | 34 | public function matches(array &$input, array &$output) 35 | { 36 | foreach ($this->tokens as $token) { 37 | if ($token->matches($input, $output)) { 38 | return true; 39 | } 40 | } 41 | 42 | return false; 43 | } 44 | 45 | public function __toString() 46 | { 47 | return implode(' | ', $this->tokens); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Tokens/EllipseToken.php: -------------------------------------------------------------------------------- 1 | token = $token; 18 | } 19 | 20 | public function matches(array &$input, array &$output) 21 | { 22 | $soutput = $output; 23 | if ($this->token->matches($input, $output)) { 24 | // array of all new output variables 25 | $all = array(); 26 | 27 | do { 28 | // check new output against original output 29 | foreach ($output as $name => $value) { 30 | if (!isset($soutput[$name]) || $soutput[$name] !== $value) { 31 | $all[$name][] = $value; 32 | } 33 | } 34 | 35 | // reset output to original state and try next match 36 | $output = $soutput; 37 | } while($this->token->matches($input, $output)); 38 | 39 | // output is new output variables plus original variables 40 | $output = $all + $soutput; 41 | 42 | return true; 43 | } 44 | 45 | return false; 46 | } 47 | 48 | public function __toString() 49 | { 50 | return $this->token . '...'; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Tokens/SentenceToken.php: -------------------------------------------------------------------------------- 1 | tokens as $token) { 19 | $this->tokens []= $token; 20 | } 21 | } else { 22 | $this->tokens []= $token; 23 | } 24 | } 25 | 26 | if (count($this->tokens) < 2) { 27 | throw new InvalidArgumentException('Sentence must contain at least 2 tokens'); 28 | } 29 | } 30 | 31 | public function matches(array &$input, array &$output) 32 | { 33 | $sinput = $input; 34 | $soutput = $output; 35 | 36 | foreach ($this->tokens as $token) { 37 | if (!$token->matches($input, $output)) { 38 | $input = $sinput; 39 | $output = $soutput; 40 | return false; 41 | } 42 | } 43 | 44 | return true; 45 | } 46 | 47 | public function __toString() 48 | { 49 | return implode(' ', array_map(function (TokenInterface $token) { 50 | // alternative token should be surrounded in parentheses 51 | if ($token instanceof AlternativeToken) { 52 | return '(' . $token . ')'; 53 | } 54 | return (string)$token; 55 | }, $this->tokens)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.4.0 (2020-12-07) 4 | 5 | * Improve test suite and add `.gitattributes` to exclude dev files from exports. 6 | Add PHP 8 support, update to PHPUnit 9 and simplify test setup. 7 | (#26 by @andreybolonin, #27 and #28 by @SimonFrings and #29 by @clue) 8 | 9 | ## 1.3.0 (2017-08-11) 10 | 11 | * Feature: Add support for custom filter callbacks 12 | (#25 by @clue) 13 | 14 | ```php 15 | $tokenizer = new Tokenizer(); 16 | $tokenizer->addFilter('ip', function ($value) { 17 | return filter_var($ip, FILTER_VALIDATE_IP); 18 | }); 19 | $tokenizer->addFilter('lower', function (&$value) { 20 | $value = strtolower($value); 21 | return true; 22 | }); 23 | 24 | $router = new Router($tokenizer); 25 | $router->add('add ', function ($args) { }); 26 | $router->add('--search=', function ($args) { }); 27 | ``` 28 | 29 | * Improve test suite by locking Travis distro so new future defaults will not break the build 30 | (#24 by @clue) 31 | 32 | ## 1.2.2 (2017-06-29) 33 | 34 | * Fix: Assume argv to be empty if not present (non-CLI SAPI mode) 35 | (#23 by @clue) 36 | 37 | * Improve test suite by adding PHPUnit to require-dev and ignoring HHVM build errors for now. 38 | (#21 and #22 by @clue) 39 | 40 | ## 1.2.1 (2016-11-14) 41 | 42 | * Fix: Use casted filter value for options with boolean values 43 | (#20 by @clue) 44 | 45 | ## 1.2.0 (2016-11-07) 46 | 47 | * Feature: Add support for predefined filters to limit accepted values and avoid requiring double dash separator 48 | (#19 by @clue) 49 | 50 | * Feature: Support preset option values, option values now accept any tokens 51 | (#17 by @clue) 52 | 53 | * Feature: Unify handling ellipses after any token and whitespace around option values 54 | (#17 by @clue) 55 | 56 | ## 1.1.0 (2016-11-06) 57 | 58 | * Feature: Support alternative groups and optional parentheses 59 | (#15 by @clue) 60 | 61 | * Fix: Fix multiple arguments, only skip whitespace inbetweeen once 62 | (#16 by @clue) 63 | 64 | ## 1.0.0 (2016-11-05) 65 | 66 | * First stable release, now following SemVer 67 | 68 | * Improve documentation and usage examples 69 | 70 | > Contains no other changes, so it's actually fully compatible with the v0.2.0 release. 71 | 72 | ## 0.2.0 (2016-11-05) 73 | 74 | * Feature / BC break: Add support for long and short options with or without option values 75 | (#8, #11,# 12 by @clue) 76 | 77 | * Feature: More flexible recursive parser with support for optional keywords and required attributes 78 | (#9, #10 by @clue) 79 | 80 | ## 0.1.0 (2016-10-14) 81 | 82 | * First tagged release 83 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | token = $token; 35 | $this->handler = $handler; 36 | } 37 | 38 | /** 39 | * Matches this route against the given $input arguments 40 | * 41 | * @param array $input 42 | * @param array $output 43 | * @return boolean 44 | * @see TokenInterface::matches() 45 | */ 46 | public function matches(array &$input, array &$output) 47 | { 48 | if ($this->token === null || $this->token->matches($input, $output)) { 49 | // excessive arguments should fail, make sure input is now empty 50 | // single remaining `--` to separate options from arguments is also accepted 51 | if (!$input || (count($input) === 1 && reset($input) === '--')) { 52 | return true; 53 | } 54 | } 55 | 56 | return false; 57 | } 58 | 59 | /** 60 | * Returns a string representation for this route token 61 | * 62 | * @return string 63 | */ 64 | public function __toString() 65 | { 66 | return (string)$this->token; 67 | } 68 | 69 | /** 70 | * Invokes the route callback handler 71 | * 72 | * @param array $args the callback arguments ($output args aquired by matching route tokens) 73 | * @return mixed returns whatever the callback handler returns 74 | * @throws \Exception throws any exception the callback handler may throw 75 | */ 76 | public function __invoke(array $args) 77 | { 78 | return call_user_func($this->handler, $args); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Tokens/ArgumentToken.php: -------------------------------------------------------------------------------- 1 | name = $name; 22 | $this->filter = $filter; 23 | $this->callback = $callback; 24 | 25 | // validate filter name by simply invoking once 26 | if ($callback === null) { 27 | $demo = ''; 28 | $this->validate($demo, false); 29 | } 30 | } 31 | 32 | public function matches(array &$input, array &$output) 33 | { 34 | $dd = false; 35 | foreach ($input as $key => $value) { 36 | if ($this->validate($value, $dd)) { 37 | unset($input[$key]); 38 | $output[$this->name] = $value; 39 | return true; 40 | } elseif ($value === '' || $value[0] !== '-' || $dd) { 41 | // this not an option => it should have matched => fail 42 | break; 43 | } elseif ($value === '--') { 44 | // found a double dash => following must be an argument 45 | $dd = true; 46 | } 47 | } 48 | 49 | return false; 50 | } 51 | 52 | public function __toString() 53 | { 54 | $ret = '<' . $this->name; 55 | if ($this->filter !== null) { 56 | $ret .= ':' . $this->filter; 57 | } 58 | $ret .= '>'; 59 | 60 | return $ret; 61 | } 62 | 63 | private function validate(&$value, $dd) 64 | { 65 | if ($this->filter === null) { 66 | // value must not start with a dash (`-`), unless it's behind a double dash (`--`) 67 | return ($dd || $value === '' || $value[0] !== '-'); 68 | } elseif ($this->callback !== null) { 69 | $callback = $this->callback; 70 | $ret = $value; 71 | if (!$callback($ret)) { 72 | return false; 73 | } 74 | $value = $ret; 75 | return true; 76 | } elseif ($this->filter === 'int' || $this->filter === 'uint') { 77 | $ret = filter_var($value, FILTER_VALIDATE_INT); 78 | if ($ret === false || ($this->filter === 'uint' && $ret < 0)) { 79 | return false; 80 | } 81 | $value = $ret; 82 | return true; 83 | } elseif ($this->filter === 'float' || $this->filter === 'ufloat') { 84 | $ret = filter_var($value, FILTER_VALIDATE_FLOAT); 85 | if ($ret === false || ($this->filter === 'ufloat' && $ret < 0)) { 86 | return false; 87 | } 88 | $value = $ret; 89 | return true; 90 | } elseif ($this->filter === 'bool') { 91 | $ret = filter_var($value, FILTER_VALIDATE_BOOLEAN, array('flags' => FILTER_NULL_ON_FAILURE)); 92 | if ($ret === null) { 93 | return false; 94 | } 95 | $value = $ret; 96 | return true; 97 | } else { 98 | throw new \InvalidArgumentException('Invalid filter name'); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Tokens/OptionToken.php: -------------------------------------------------------------------------------- 1 | name = $name; 30 | $this->placeholder = $placeholder; 31 | $this->required = $required; 32 | } 33 | 34 | public function matches(array &$input, array &$output) 35 | { 36 | $len = strlen($this->name); 37 | $foundName = null; 38 | 39 | foreach ($input as $key => $value) { 40 | if ($foundName !== null) { 41 | // already found a match with no value appended in previous iteration 42 | 43 | if ($this->validate($value)) { 44 | // found a valid value after name in previous iteration 45 | unset($input[$foundName]); 46 | unset($input[$key]); 47 | $output[ltrim($this->name, '-')] = $value; 48 | return true; 49 | } elseif (!$this->required) { 50 | // option does not require a valid value => break in order to use `false` 51 | break; 52 | } else { 53 | // we expected a value but actually found another option / invalid value 54 | // skip and keep searching 55 | $foundName = null; 56 | } 57 | } 58 | 59 | if (strpos($value, $this->name) === 0) { 60 | // found option with this prefix 61 | 62 | if ($value === $this->name) { 63 | // this is an exact match (no value appended) 64 | 65 | // if this accepts a value, check next iteration for value 66 | if ($this->placeholder !== null) { 67 | $foundName = $key; 68 | continue; 69 | } 70 | 71 | // use `false` value for compatibility with `getopt()` etc. 72 | $value = false; 73 | } elseif ($this->placeholder !== null && $value[$len] === '=') { 74 | // value accepted and actually appended 75 | // actual option value is everything after `=` 76 | $value = substr($value, $len + 1); 77 | } elseif ($this->placeholder !== null && $this->name[1] !== '-') { 78 | // value accepted and appended right after option name (only for short options) 79 | $value = substr($value, $len); 80 | } else { 81 | // no value accepted or not followed by `=` => keep searching 82 | continue; 83 | } 84 | 85 | if (!$this->validate($value)) { 86 | continue; 87 | } 88 | 89 | unset($input[$key]); 90 | $output[ltrim($this->name, '-')] = $value; 91 | return true; 92 | } elseif ($value === '--') { 93 | // double dash found => no option after this point 94 | break; 95 | } 96 | } 97 | 98 | if ($foundName !== null && !$this->required) { 99 | // found a key in the last iteration and no following value 100 | unset($input[$foundName]); 101 | $output[ltrim($this->name, '-')] = false; 102 | return true; 103 | } 104 | 105 | return false; 106 | } 107 | 108 | public function __toString() 109 | { 110 | $ret = $this->name; 111 | 112 | if ($this->placeholder !== null) { 113 | if ($this->required) { 114 | if ($this->placeholder instanceof SentenceToken || $this->placeholder instanceof AlternativeToken || $this->placeholder instanceof EllipseToken) { 115 | $ret .= '=(' . $this->placeholder . ')'; 116 | } else { 117 | $ret .= '=' . $this->placeholder; 118 | } 119 | } else { 120 | $ret .= '[=' . $this->placeholder . ']'; 121 | } 122 | } 123 | 124 | return $ret; 125 | } 126 | 127 | private function validate(&$value) 128 | { 129 | if ($this->placeholder !== null) { 130 | $input = array($value); 131 | $output = array(); 132 | 133 | // filter the value through the placeholder 134 | if (!$this->placeholder->matches($input, $output)) { 135 | return false; 136 | } 137 | 138 | // if the placeholder returned a filtered value, use this one 139 | if ($output) { 140 | $temp = reset($output); 141 | 142 | // if the value parsed as an option, its value will be `false` 143 | // rather keep name in that case, otherwise use parsed value 144 | if ($temp !== false || $value === '' || $value[0] !== '-') { 145 | $value = $temp; 146 | } 147 | } 148 | } 149 | return true; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | tokenizer = $tokenizer; 31 | } 32 | 33 | /** 34 | * Registers a new Route with this Router 35 | * 36 | * @param string $route the route expression to match 37 | * @param callable $handler route callback that will be executed when this route expression matches 38 | * @return Route 39 | * @throws \InvalidArgumentException if the route expression is invalid 40 | */ 41 | public function add($route, $handler) 42 | { 43 | if (trim($route) === '') { 44 | $token = null; 45 | } else { 46 | $token = $this->tokenizer->createToken($route); 47 | } 48 | $route = new Route($token, $handler); 49 | 50 | $this->routes[] = $route; 51 | 52 | return $route; 53 | } 54 | 55 | /** 56 | * Removes the given route object from the registered routes 57 | * 58 | * @param Route $route 59 | * @throws \UnderflowException if the route does not exist 60 | */ 61 | public function remove(Route $route) 62 | { 63 | $id = array_search($route, $this->routes); 64 | if ($id === false) { 65 | throw new \UnderflowException('Given Route not found'); 66 | } 67 | 68 | unset($this->routes[$id]); 69 | } 70 | 71 | /** 72 | * Returns an array of all registered routes 73 | * 74 | * @return Route[] 75 | */ 76 | public function getRoutes() 77 | { 78 | return $this->routes; 79 | } 80 | 81 | /** 82 | * Executes by matching the `argv` against all registered routes and then exits 83 | * 84 | * This is a convenience method that will match and execute a route and then 85 | * exit the program without returning. 86 | * 87 | * If no route could be found or if the route callback throws an Exception, 88 | * it will print out an error message to STDERR and set an appropriate 89 | * non-zero exit code. 90 | * 91 | * Note that this is for convenience only and only useful for the most 92 | * simple of all programs. If you need more control, then consider using 93 | * the underlying `handleArgv()` method and handle any error situations 94 | * yourself. 95 | * 96 | * You can explicitly pass in your `argv` or it will automatically use the 97 | * values from the $_SERVER superglobal. The `argv` is an array that will 98 | * always start with the calling program as the first element. We simply 99 | * ignore this first element and then process the remaining elements 100 | * according to the registered routes. 101 | * 102 | * @param array $argv 103 | * @uses self::handleArgv() 104 | */ 105 | public function execArgv(array $argv = null) 106 | { 107 | try { 108 | $this->handleArgv($argv); 109 | // @codeCoverageIgnoreStart 110 | } catch (NoRouteFoundException $e) { 111 | fwrite(STDERR, 'Usage Error: ' . $e->getMessage() . PHP_EOL); 112 | 113 | // sysexits.h: #define EX_USAGE 64 /* command line usage error */ 114 | exit(64); 115 | } catch (Exception $e) { 116 | fwrite(STDERR, 'Program Error: ' . $e->getMessage() . PHP_EOL); 117 | 118 | // stdlib.h: #define EXIT_FAILURE 1 119 | exit(1); 120 | } 121 | // @codeCoverageIgnoreEnd 122 | } 123 | 124 | /** 125 | * Executes by matching the `argv` against all registered routes and then returns 126 | * 127 | * Unlike `execArgv()` this method will try to execute the route callback 128 | * and then return whatever the route callback returned. 129 | * 130 | * If no route could be found or if the route callback throws an Exception, 131 | * it will throw an Exception. 132 | * 133 | * You can explicitly pass in your `argv` or it will automatically use the 134 | * values from the $_SERVER superglobal. The `argv` is an array that will 135 | * always start with the calling program as the first element. We simply 136 | * ignore this first element and then process the remaining elements 137 | * according to the registered routes. 138 | * 139 | * @param array|null $argv 140 | * @return mixed will be the return value from the matched route callback 141 | * @throws Exception if the executed route callback throws an exception 142 | * @throws NoRouteFoundException If no matching route could be found 143 | * @uses self::handleArgs() 144 | */ 145 | public function handleArgv(array $argv = null) 146 | { 147 | if ($argv === null) { 148 | $argv = isset($_SERVER['argv']) ? $_SERVER['argv'] : array(); 149 | } 150 | array_shift($argv); 151 | 152 | return $this->handleArgs($argv); 153 | } 154 | 155 | /** 156 | * Executes by matching the given args against all registered routes and then returns 157 | * 158 | * Unlike `handleArgv()` this method will use the complete `$args` array 159 | * to match the registered routes (i.e. it will not ignore the first element). 160 | * This is particularly useful if you build this array yourself or if you 161 | * use an interactive command line interface (CLI) and ask your user to 162 | * supply the arguments. 163 | * 164 | * The arguments have to be given as an array of individual elements. If you 165 | * only have a command line string that you want to split into an array of 166 | * individual command line arguments, consider using clue/arguments. 167 | * 168 | * @param array $args 169 | * @return mixed will be the return value from the matched route callback 170 | * @throws Exception if the executed route callback throws an exception 171 | * @throws NoRouteFoundException If no matching route could be found 172 | */ 173 | public function handleArgs(array $args) 174 | { 175 | foreach ($this->routes as $route) { 176 | $input = $args; 177 | $output = array(); 178 | if ($route->matches($input, $output)) { 179 | return $route($output); 180 | } 181 | } 182 | 183 | throw new NoRouteFoundException('No matching route found'); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/Tokens/Tokenizer.php: -------------------------------------------------------------------------------- 1 | filters[$name] = $filter; 33 | } 34 | 35 | /** 36 | * Creates a Token from the given route expression 37 | * 38 | * @param string $input 39 | * @return TokenInterface 40 | * @throws InvalidArgumentException if the route expression can not be parsed 41 | */ 42 | public function createToken($input) 43 | { 44 | $i = 0; 45 | $token = $this->readAlternativeSentenceOrSingle($input, $i); 46 | 47 | if (isset($input[$i])) { 48 | throw new \InvalidArgumentException('Invalid root token, expression has superfluous contents'); 49 | } 50 | 51 | return $token; 52 | } 53 | 54 | private function readSentenceOrSingle($input, &$i) 55 | { 56 | $tokens = array(); 57 | 58 | while (true) { 59 | $previous = $i; 60 | $this->consumeOptionalWhitespace($input, $i); 61 | 62 | // end of input reached or end token found 63 | if (!isset($input[$i]) || strpos('])|', $input[$i]) !== false) { 64 | break; 65 | } 66 | 67 | // cursor must be moved due to whitespace if there's another token 68 | if ($previous === $i && $tokens) { 69 | throw new InvalidArgumentException('Missing whitespace between tokens'); 70 | } 71 | 72 | $tokens []= $this->readToken($input, $i); 73 | } 74 | 75 | // return a single token as-is 76 | if (isset($tokens[0]) && !isset($tokens[1])) { 77 | return $tokens[0]; 78 | } 79 | 80 | // otherwise wrap in a sentence-token 81 | return new SentenceToken($tokens); 82 | } 83 | 84 | private function consumeOptionalWhitespace($input, &$i) 85 | { 86 | // skip whitespace characters 87 | for (;isset($input[$i]) && in_array($input[$i], $this->ws); ++$i); 88 | } 89 | 90 | private function readToken($input, &$i, $readEllipses = true) 91 | { 92 | if ($input[$i] === '<') { 93 | $token = $this->readArgument($input, $i); 94 | } elseif ($input[$i] === '[') { 95 | $token = $this->readOptionalBlock($input, $i); 96 | } elseif ($input[$i] === '(') { 97 | $token = $this->readParenthesesBlock($input, $i); 98 | } else { 99 | $token = $this->readWord($input, $i); 100 | } 101 | 102 | // skip trailing whitespace to check for ellipses 103 | $start = $i; 104 | $this->consumeOptionalWhitespace($input, $start); 105 | 106 | // found `...` after some optional whitespace 107 | if ($readEllipses && substr($input, $start, 3) === '...') { 108 | $token = new EllipseToken($token); 109 | $i = $start + 3; 110 | } 111 | 112 | return $token; 113 | } 114 | 115 | private function readArgument($input, &$i) 116 | { 117 | // start of argument found, search end token `>` 118 | for ($start = $i++; isset($input[$i]) && $input[$i] !== '>'; ++$i); 119 | 120 | // no end token found, syntax error 121 | if (!isset($input[$i])) { 122 | throw new InvalidArgumentException('Missing end of argument'); 123 | } 124 | 125 | // everything between `<` and `>` is the argument name 126 | $word = substr($input, $start + 1, $i++ - $start - 1); 127 | 128 | $parts = explode(':', $word, 2); 129 | $word = trim($parts[0]); 130 | $filter = isset($parts[1]) ? trim($parts[1]) : null; 131 | $callback = null; 132 | 133 | if ($filter !== null && isset($this->filters[$filter])) { 134 | $callback = $this->filters[$filter]; 135 | } 136 | 137 | return new ArgumentToken($word, $filter, $callback); 138 | } 139 | 140 | private function readOptionalBlock($input, &$i) 141 | { 142 | // advance to contents of optional block and read inner sentence 143 | $i++; 144 | $token = $this->readAlternativeSentenceOrSingle($input, $i); 145 | 146 | // above should stop at end token, otherwise syntax error 147 | if (!isset($input[$i]) || $input[$i] !== ']') { 148 | throw new InvalidArgumentException('Missing end of optional block'); 149 | } 150 | 151 | // skip end token 152 | $i++; 153 | 154 | return new OptionalToken($token); 155 | } 156 | 157 | private function readParenthesesBlock($input, &$i) 158 | { 159 | // advance to contents of parentheses block and read inner sentence 160 | $i++; 161 | $token = $this->readAlternativeSentenceOrSingle($input, $i); 162 | 163 | // above should stop and end token, otherwise syntax error 164 | if (!isset($input[$i]) || $input[$i] !== ')') { 165 | throw new InvalidArgumentException('Missing end of alternative block'); 166 | } 167 | 168 | // skip end token 169 | $i++; 170 | 171 | return $token; 172 | } 173 | 174 | /** 175 | * reads a complete sentence token until end of group 176 | * 177 | * An "alternative sentence" may contain the following tokens: 178 | * - an alternative group (which may consist of individual sentences separated by `|`) 179 | * - a sentence (which may consist of multiple tokens) 180 | * - a single token 181 | * 182 | * @param string $input 183 | * @param int $i 184 | * @throws InvalidArgumentException 185 | * @return TokenInterface 186 | */ 187 | private function readAlternativeSentenceOrSingle($input, &$i) 188 | { 189 | $tokens = array(); 190 | 191 | while (true) { 192 | $tokens []= $this->readSentenceOrSingle($input, $i); 193 | 194 | // end of input reached or end token found 195 | if (!isset($input[$i]) || strpos('])', $input[$i]) !== false) { 196 | break; 197 | } 198 | 199 | // cursor now at alternative symbol (all other symbols are already handled) 200 | // skip alternative mark and continue with next alternative 201 | $i++; 202 | } 203 | 204 | // return a single token as-is 205 | if (isset($tokens[0]) && !isset($tokens[1])) { 206 | return $tokens[0]; 207 | } 208 | 209 | return new AlternativeToken($tokens); 210 | } 211 | 212 | private function readWord($input, &$i) 213 | { 214 | // static word token, buffer until next whitespace or special char 215 | preg_match('/[^\[\]\(\)\|\=\.\s]+/', $input, $matches, 0, $i); 216 | 217 | $word = isset($matches[0]) ? $matches[0] : ''; 218 | $i += strlen($word); 219 | 220 | if (isset($word[0]) && $word[0] === '-') { 221 | // starts with a `-` => this is an option 222 | 223 | // skip optional whitespace after option name in order to search for option value 224 | $start = $i; 225 | $this->consumeOptionalWhitespace($input, $start); 226 | 227 | if (isset($input[$start]) && $input[$start] === '[') { 228 | // opening bracket found (possibly an optional option value) 229 | 230 | // skip optional whitespace after bracket in order to search for `=` 231 | $start++; 232 | $this->consumeOptionalWhitespace($input, $start); 233 | 234 | if (isset($input[$start]) && $input[$start] === '=') { 235 | // found `[=` for optional value, read placeholder token and expect closing bracket 236 | // placeholder may contain alternatives because the surrounded brackets make this unambiguous 237 | $i = $start + 1; 238 | $placeholder = $this->readAlternativeSentenceOrSingle($input, $i); 239 | 240 | if (!isset($input[$i]) || $input[$i] !== ']') { 241 | throw new InvalidArgumentException('Missing end of optional option value'); 242 | } 243 | 244 | // skip trailing closing bracket 245 | $i++; 246 | $required = false; 247 | } else { 248 | // ignore opening bracket because it is not part of an option value 249 | $required = false; 250 | $placeholder = null; 251 | } 252 | } elseif (isset($input[$start]) && $input[$start] === '=') { 253 | // found `=` for required value, skip whitespace and read until end of token 254 | $i = $start + 1; 255 | $this->consumeOptionalWhitespace($input, $i); 256 | 257 | // placeholder may only contain single token because it's terminated at ambiguous whitespace 258 | // we explicitly skip consuming the ellipses as part of the option value here 259 | // trailing ellipses should be part of the whole option, not only its value 260 | $placeholder = $this->readToken($input, $i, false); 261 | $required = true; 262 | } else { 263 | // ignore unknown character at cursor position because it is not part of this option value 264 | $required = false; 265 | $placeholder = null; 266 | } 267 | 268 | $token = new OptionToken($word, $placeholder, $required); 269 | } else{ 270 | $token = new WordToken($word); 271 | } 272 | 273 | return $token; 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clue/commander 2 | 3 | [![CI status](https://github.com/clue/commander/workflows/CI/badge.svg)](https://github.com/clue/commander/actions) 4 | [![installs on Packagist](https://img.shields.io/packagist/dt/clue/commander?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/clue/commander) 5 | 6 | Finally a sane way to register available commands and arguments and match your command line in PHP. 7 | 8 | You want to build a command line interface (CLI) tool in PHP which accepts 9 | additional arguments and you now want to route these to individual functions? 10 | Then this library is for you! 11 | 12 | This is also useful for interactive CLI tools or anywhere where you can break up 13 | a command line string into an array of command line arguments and you now want 14 | to execute individual functions depending on the arguments given. 15 | 16 | **Table of contents** 17 | 18 | * [Support us](#support-us) 19 | * [Quickstart example](#quickstart-example) 20 | * [Usage](#usage) 21 | * [Router](#router) 22 | * [add()](#add) 23 | * [remove()](#remove) 24 | * [getRoutes()](#getroutes) 25 | * [execArgv()](#execargv) 26 | * [handleArgv()](#handleargv) 27 | * [handleArgs()](#handleargs) 28 | * [Route](#route) 29 | * [NoRouteFoundException](#noroutefoundexception) 30 | * [Install](#install) 31 | * [Tests](#tests) 32 | * [License](#license) 33 | * [More](#more) 34 | 35 | ## Support us 36 | 37 | We invest a lot of time developing, maintaining and updating our awesome 38 | open-source projects. You can help us sustain this high-quality of our work by 39 | [becoming a sponsor on GitHub](https://github.com/sponsors/clue). Sponsors get 40 | numerous benefits in return, see our [sponsoring page](https://github.com/sponsors/clue) 41 | for details. 42 | 43 | Let's take these projects to the next level together! 🚀 44 | 45 | ### Quickstart example 46 | 47 | The following example code demonstrates how this library can be used to build 48 | a very simple command line interface (CLI) tool that accepts command line 49 | arguments passed to this program: 50 | 51 | ```php 52 | $router = new Clue\Commander\Router(); 53 | $router->add('exit []', function (array $args) { 54 | exit(isset($args['code']) ? $args['code'] : 0); 55 | }); 56 | $router->add('sleep ', function (array $args) { 57 | sleep($args['seconds']); 58 | }); 59 | $router->add('echo ...', function (array $args) { 60 | echo join(' ', $args['words']) . PHP_EOL; 61 | }); 62 | $router->add('[--help | -h]', function () use ($router) { 63 | echo 'Usage:' . PHP_EOL; 64 | foreach ($router->getRoutes() as $route) { 65 | echo ' ' .$route . PHP_EOL; 66 | } 67 | }); 68 | 69 | $router->execArgv(); 70 | ``` 71 | 72 | See also the [examples](examples). 73 | 74 | ## Usage 75 | 76 | ### Router 77 | 78 | The `Router` is the main class in this package. 79 | 80 | It is responsible for registering new Routes, matching the given args against 81 | these routes and then executing the registered route callback. 82 | 83 | ```php 84 | $router = new Router(); 85 | ``` 86 | 87 | > Advanced usage: The `Router` accepts an optional [`Tokenizer`](#tokenizer) 88 | instance as the first parameter to the constructor. 89 | 90 | #### add() 91 | 92 | The `add(string $route, callable $handler): Route` method can be used to 93 | register a new [`Route`](#route) with this Router. 94 | 95 | It accepts a route expression to match and a route callback that will be 96 | executed when this route expression matches. 97 | 98 | This is very similar to how common PHP (micro-)frameworks offer "HTTP routers" 99 | to route incoming HTTP requests to the corresponding "controller functions": 100 | 101 | ```php 102 | $route = $router->add($path, $fn); 103 | ``` 104 | 105 | The route expression uses a custom domain-specific language (DSL) which aims to 106 | be so simple that both consumers of this library 107 | (i.e. developers) and users of your resulting tools should be able to understand 108 | them. 109 | 110 | Note that this is a left-associative grammar (LAG) and all tokens are greedy. 111 | This means that the tokens will be processed from left to right and each token 112 | will try to match as many of the input arguments as possible. 113 | This implies that certain route expressions make little sense, such as having 114 | an optional argument after an argument with ellipses. 115 | For more details, see below. 116 | 117 | You can use an empty string like this to match when no arguments have been given: 118 | 119 | ```php 120 | $router->add('', function() { 121 | echo 'No arguments given. Need help?' . PHP_EOL; 122 | }); 123 | // matches: (empty string) 124 | // does not match: hello (too many arguments) 125 | ``` 126 | 127 | You can use any number of static keywords like this: 128 | 129 | ```php 130 | $router->add('user list', function () { 131 | echo 'Here are all our users…' . PHP_EOL; 132 | }); 133 | // matches: user list 134 | // does not match: user (missing required keyword) 135 | // does not match: user list hello (too many arguments) 136 | ``` 137 | 138 | You can use alternative blocks to support any of the static keywords like this: 139 | 140 | ```php 141 | $router->add('user (list | listing | ls)', function () { 142 | echo 'Here are all our users…' . PHP_EOL; 143 | }); 144 | // matches: user list 145 | // matches: user listing 146 | // matches: user ls 147 | // does not match: user (missing required keyword) 148 | // does not match: user list hello (too many arguments) 149 | ``` 150 | 151 | Note that alternative blocks can be added to pretty much any token in your route 152 | expression. 153 | Note that alternative blocks do not require parentheses and the alternative mark 154 | (`|`) always works at the current block level, which may not always be obvious. 155 | Unless you add some parentheses, `a b | c d` will be be interpreted as 156 | `(a b) | (c d)` by default. 157 | Parentheses can be used to interpret this as `a (b | c) d` instead. 158 | In particular, you can also combine alternative blocks with optional blocks 159 | (see below) in order to optionally accept only one of the alternatives, but not 160 | multiple. 161 | 162 | You can use any number of placeholders to mark required arguments like this: 163 | 164 | ```php 165 | $router->add('user add ', function (array $args) { 166 | assert(is_string($args['name'])); 167 | var_dump($args['name']); 168 | }); 169 | // matches: user add clue 170 | // does not match: user add (missing required argument) 171 | // does not match: user add hello world (too many arguments) 172 | // does not match: user add --test (argument looks like an option) 173 | 174 | // matches: user add -- clue (value: clue) 175 | // matches: user add -- --test (value: --test) 176 | // matches: user add -- -nobody- (value: -nobody-) 177 | // matches: user add -- -- (value: --) 178 | ``` 179 | 180 | Note that arguments that start with a dash (`-`) are not simply accepted in the 181 | user input, because they may be confused with (optional) options (see below). 182 | If users wish to process arguments that start with a dash (`-`), they either 183 | have to use filters (see below) or may use a double dash separator (`--`), 184 | as everything after this separator will be processed as-is. 185 | See also the last examples above that demonstrate this behavior. 186 | 187 | You can use one the predefined filters to limit what values are accepted like this: 188 | 189 | ```php 190 | $router->add('user ban ', function (array $args) { 191 | assert(is_int($args['id'])); 192 | assert(is_bool($args['force'])); 193 | }); 194 | // matches: user ban 10 true 195 | // matches: user ban 10 0 196 | // matches: user ban -10 yes 197 | // matches: user ban -- -10 no 198 | // does not match: user ban 10 (missing required argument) 199 | // does not match: user ban hello true (invalid value does not validate) 200 | ``` 201 | 202 | Note that the filters also return the value casted to the correct data type. 203 | Also note how using the double dash separator (`--`) is optional when matching 204 | a filtered value. 205 | The following predefined filters are currently available: 206 | 207 | * `int` accepts any positive or negative integer value, such as `10` or `-4` 208 | * `uint` accepts any positive (unsigned) integer value, such `10` or `0` 209 | * `float` accepts any positive or negative float value, such as `1.5` or `-2.3` 210 | * `ufloat` accepts any positive (unsigned) float value, such as `1.5` or `0` 211 | * `bool` accepts any boolean value, such as `yes/true/1` or `no/false/0` 212 | 213 | > If you want to add a custom filter function, see also [`Tokenizer`](#tokenizer) 214 | for advanced usage below. 215 | 216 | You can mark arguments as optional by enclosing them in square brackets like this: 217 | 218 | ```php 219 | $router->add('user search []', function (array $args) { 220 | assert(!isset($args['query']) || is_string($args['query'])); 221 | var_dump(isset($args['query']); 222 | }); 223 | // matches: user search 224 | // matches: user search clue 225 | // does not match: user search hello world (too many arguments) 226 | ``` 227 | 228 | Note that square brackets can be added to pretty much any token in your route 229 | expression, however they are most commonly used for arguments as above or for 230 | optional options as below. 231 | Optional tokens can appear anywhere in the route expression, but keep in mind 232 | that the tokens will be matched from left to right, so if the optional token 233 | matches, then the remainder will be processed by the following tokens. 234 | As a rule of thumb, make sure optional tokens are near the end of your route 235 | expressions and you won't notice this subtle effect. 236 | Optional blocks accept alternative groups, so that `[a | b]` is actually 237 | equivalent to the longer form `[(a | b)]`. 238 | In particular, this is often used for alternative options as below. 239 | 240 | You can accept any number of arguments by appending ellipses like this: 241 | 242 | ```php 243 | $router->add('user delete ...', function (array $args) { 244 | assert(is_array($args); 245 | assert(count($args) > 0); 246 | var_dump($args['names']); 247 | }); 248 | // matches: user delete clue 249 | // matches: user delete hello world 250 | // does not match: user delete (missing required argument) 251 | ``` 252 | 253 | Note that trailing ellipses can be added to any argument, word or option token 254 | in your route expression. They are most commonly used for arguments as above. 255 | The above requires at least one argument, see the following if you want this 256 | to be completely optional. 257 | Technically, the ellipse tokens can appear anywhere in the route expression, but 258 | keep in mind that the tokens will be matched from the left to the right, so if 259 | the ellipse matches, it will consume all input arguments and not leave anything 260 | for following tokens. 261 | As a rule of thumb, make sure ellipse tokens are near the end of your route 262 | expression and you won't notice this subtle effect. 263 | 264 | You can accept any number of optional arguments by appending ellipses within square brackets like this: 265 | 266 | ```php 267 | $router->add('user dump [...]', function (array $args) { 268 | if (isset($args['names'])) { 269 | assert(is_array($args); 270 | assert(count($args) > 0); 271 | var_dump($args['names']); 272 | } else { 273 | var_dump('no names'); 274 | } 275 | }); 276 | // matches: user dump 277 | // matches: user dump clue 278 | // matches: user dump hello world 279 | ``` 280 | 281 | The above does not require any arguments, it works with zero or more arguments. 282 | 283 | You can add any number of optional short or long options like this: 284 | 285 | ```php 286 | $router->add('user list [--json] [-f]', function (array $args) { 287 | assert(!isset($args['json']) || $args['json'] === false); 288 | assert(!isset($args['f']) || $args['f'] === false); 289 | }); 290 | // matches: user list 291 | // matches: user list --json 292 | // matches: user list -f 293 | // matches: user list -f --json 294 | // matches: user -f list 295 | // matches: --json user list 296 | ``` 297 | 298 | As seen in the example, options in the `$args` array can either be unset when 299 | they have not been passed in the user input or set to `false` when they have 300 | been passed (which is in line with how other parsers such as `getopt()` work). 301 | Note that options are accepted anywhere in the user input argument, regardless 302 | of where they have been defined. 303 | Note that the square brackets are in the route expression are required to mark 304 | this optional as optional, you can also omit these square brackets if you really 305 | want a required option. 306 | 307 | You can combine short and long options in an alternative block like this: 308 | 309 | ```php 310 | $router->add('user setup [--help | -h]', function (array $args) { 311 | assert(!isset($args['help']) || $args['help'] === false); 312 | assert(!isset($args['h']) || $args['h'] === false); 313 | assert(!isset($args['help'], $args['h']); 314 | }); 315 | // matches: user setup 316 | // matches: user setup --help 317 | // matches: user setup -h 318 | // does not match: user setup --help -h (only accept eithers, not both) 319 | ``` 320 | 321 | As seen in the example, this optionally accepts either the short or the long 322 | option anywhere in the user input, but never both at the same time. 323 | 324 | You can optionally accept or require values for short and long options like this: 325 | 326 | ```php 327 | $router->add('[--sort[=]] [-i=] user list', function (array $args) { 328 | assert(!isset($args['sort']) || $args['sort'] === false || is_string($args['sort'])); 329 | assert(!isset($args['i']) || is_int($args['i'])); 330 | }); 331 | // matches: user list 332 | // matches: user list --sort 333 | // matches: user list --sort=size 334 | // matches: user list --sort size 335 | // matches: user list -i=10 336 | // matches: user list -i 10 337 | // matches: user list -i10 338 | // matches: user list -i=-10 339 | // matches: user list -i -10 340 | // matches: user list -i-10 341 | // matches: user -i=10 list 342 | // matches: --sort -- user list 343 | // matches: --sort size user list 344 | // matches: user list --sort -i=10 345 | // does not match: user list -i (missing option value) 346 | // does not match: user list -i --sort (missing option value) 347 | // does not match: user list -i=a (invalid value does not validate) 348 | // does not match: --sort user list (user will be interpreted as option value) 349 | // does not match: user list --sort -2 (value looks like an option) 350 | ``` 351 | 352 | As seen in the example, option values in the `$args` array will be given as 353 | strings or their filtered and casted value if passed in the user input. 354 | Both short and long options can accept values with the recommended equation 355 | symbol syntax (`-i=10` and `--sort=size` respectively) in the user input. 356 | Both short and long options can also accept values with the common space-separated 357 | syntax (`-i 10` and `--sort size` respectively) in the user input. 358 | Short options can also accept values with the common concatenated syntax 359 | with no separator inbetween (`-i10`) in the user input. 360 | Note that it is highly recommended to always make sure any options that accept 361 | values are near the left side of your route expression. 362 | This is needed in order to make sure space-separated values are consumed as 363 | option values instead of being misinterpreted as keywords or arguments. 364 | 365 | You can limit the values for short and long options to a given preset like this: 366 | 367 | ```php 368 | $router->add('[--ask=(yes | no)] [-l[=0]] user purge', function (array $args) { 369 | assert(!isset($args['ask']) || $args['sort'] === 'yes' || $args['sort'] === 'no'); 370 | assert(!isset($args['l']) || $args['l'] === '0'); 371 | }); 372 | // matches: user purge 373 | // matches: user purge --ask=yes 374 | // matches: user purge --ask=no 375 | // matches: user purge -l 376 | // matches: user purge -l=0 377 | // matches: user purge -l 0 378 | // matches: user purge -l0 379 | // matches: user purge -l --ask=no 380 | // does not match: user purge --ask (missing option value) 381 | // does not match: user purge --ask=maybe (invalid option value) 382 | // does not match: user purge -l4 (invalid option value) 383 | ``` 384 | 385 | As seen in the example, option values can be restricted to a given preset of 386 | values by using any of the above tokens. 387 | Technically, it's valid to use any of the above tokens to restrict the option 388 | values. 389 | In practice, this is mostly used for static keyword tokens or alternative groups 390 | thereof. 391 | It's recommended to always use parentheses for optional groups, however they're 392 | not strictly required within options with optional values. 393 | This also helps making it more obvious `[--ask=(yes | no)]` would accept either 394 | option value, while the (less useful) expression `[--ask=yes | no]` would 395 | accept either the option `--ask=yes` or the static keyword `no`. 396 | 397 | #### remove() 398 | 399 | The `remove(Route $route): void` method can be used to remove the given 400 | [`Route`](#route) object from the registered routes. 401 | 402 | ```php 403 | $route = $router->add('hello ', $fn); 404 | $router->remove($route); 405 | ``` 406 | 407 | It will throw an `UnderflowException` if the given route does not exist. 408 | 409 | #### getRoutes() 410 | 411 | The `getRoutes(): Route[]` method can be used to return an array of all 412 | registered [`Route`](#route) objects. 413 | 414 | ```php 415 | echo 'Usage help:' . PHP_EOL; 416 | foreach ($router->getRoutes() as $route) { 417 | echo $route . PHP_EOL; 418 | } 419 | ``` 420 | 421 | This array will be empty if you have not added any routes yet. 422 | 423 | #### execArgv() 424 | 425 | The `execArgv(array $argv = null): void` method can be used to 426 | execute by matching the `argv` against all registered routes and then exit. 427 | 428 | You can explicitly pass in your `$argv` or it will automatically use the 429 | values from the `$_SERVER` superglobal. The `argv` is an array that will 430 | always start with the calling program as the first element. We simply 431 | ignore this first element and then process the remaining elements 432 | according to the registered routes. 433 | 434 | This is a convenience method that will match and execute a route and then 435 | exit the program without returning. 436 | 437 | If no route could be found or if the route callback throws an Exception, 438 | it will print out an error message to STDERR and set an appropriate 439 | non-zero exit code. 440 | 441 | Note that this is for convenience only and only useful for the most 442 | simple of all programs. If you need more control, then consider using 443 | the underlying [`handleArgv()`](#handleargv) method and handle any error situations 444 | yourself. 445 | 446 | #### handleArgv() 447 | 448 | The `handleArgv(array $argv = null): mixed` method can be used to 449 | execute by matching the `argv` against all registered routes and then return. 450 | 451 | You can explicitly pass in your `$argv` or it will automatically use the 452 | values from the `$_SERVER` superglobal. The `argv` is an array that will 453 | always start with the calling program as the first element. We simply 454 | ignore this first element and then process the remaining elements 455 | according to the registered routes. 456 | 457 | Unlike [`execArgv()`](#execargv) this method will try to execute the route callback 458 | and then return whatever the route callback returned. 459 | 460 | ```php 461 | $router->add('hello ', function (array $args) { 462 | return strlen($args[$name]); 463 | }); 464 | 465 | $length = $router->handleArgv(array('program', 'hello', 'test')); 466 | 467 | assert($length === 4); 468 | ``` 469 | 470 | If no route could be found, it will throw a [`NoRouteFoundException`](#noroutefoundexception). 471 | 472 | ```php 473 | // throws NoRouteFoundException 474 | $router->handleArgv(array('program', 'invalid')); 475 | ``` 476 | 477 | If the route callback throws an `Exception`, it will pass through this `Exception`. 478 | 479 | ```php 480 | $router->add('hello ', function (array $args) { 481 | if ($args['name'] === 'admin') { 482 | throw new InvalidArgumentException(); 483 | } 484 | 485 | return strlen($args['name']); 486 | }); 487 | 488 | // throws InvalidArgumentException 489 | $router->handleArgv(array('program', 'hello', 'admin')); 490 | ``` 491 | 492 | #### handleArgs() 493 | 494 | The `handleArgs(array $args): mixed` method can be used to 495 | execute by matching the given args against all registered routes and then return. 496 | 497 | Unlike [`handleArgv()`](#handleargv) this method will use the complete `$args` array 498 | to match the registered routes (i.e. it will not ignore the first element). 499 | This is particularly useful if you build this array yourself or if you 500 | use an interactive command line interface (CLI) and ask your user to 501 | supply the arguments. 502 | 503 | ```php 504 | $router->add('hello ', function (array $args) { 505 | return strlen($args[$name]); 506 | }); 507 | 508 | $length = $router->handleArgs(array('hello', 'test')); 509 | 510 | assert($length === 4); 511 | ``` 512 | 513 | The arguments have to be given as an array of individual elements. If you 514 | only have a command line string that you want to split into an array of 515 | individual command line arguments, consider using 516 | [clue/arguments](https://github.com/clue/arguments). 517 | 518 | ```php 519 | $line = fgets(STDIN, 2048); 520 | assert($line === 'hello "Christian Lück"'); 521 | 522 | $args = Clue\Arguments\split($line); 523 | assert($args === array('hello', 'Christian Lück')); 524 | 525 | $router->handleArgs($args); 526 | ``` 527 | 528 | If no route could be found, it will throw a [`NoRouteFoundException`](#noroutefoundexception). 529 | 530 | ```php 531 | // throws NoRouteFoundException 532 | $router->handleArgs(array('invalid')); 533 | ``` 534 | 535 | If the route callback throws an `Exception`, it will pass through this `Exception`. 536 | 537 | ```php 538 | $router->add('hello ', function (array $args) { 539 | if ($args['name'] === 'admin') { 540 | throw new InvalidArgumentException(); 541 | } 542 | 543 | return strlen($args['name']); 544 | }); 545 | 546 | // throws InvalidArgumentException 547 | $router->handleArgs(array('hello', 'admin')); 548 | ``` 549 | 550 | ### Route 551 | 552 | The `Route` represents a single registered route within the [Router](#router). 553 | 554 | It holds the required route tokens to match and the route callback to 555 | execute if this route matches. 556 | 557 | See [`Router`](#router). 558 | 559 | ### NoRouteFoundException 560 | 561 | The `NoRouteFoundException` will be raised by [`handleArgv()`](#handleargv) 562 | or [`handleArgs()`](#handleargs) if no matching route could be found. 563 | It extends PHP's built-in `RuntimeException`. 564 | 565 | ### Tokenizer 566 | 567 | The `Tokenizer` class is responsible for parsing a route expression into a 568 | valid token instance. 569 | This class is mostly used internally and not something you have to worry about 570 | in most cases. 571 | 572 | If you need custom logic for your route expression, you may explicitly pass an 573 | instance of your `Tokenizer` to the constructor of the `Router`: 574 | 575 | ```php 576 | $tokenizer = new Tokenizer(); 577 | 578 | $router = new Router($tokenizer); 579 | ``` 580 | 581 | #### addFilter() 582 | 583 | The `addFilter(string $name, callable $filter): void` method can be used to 584 | add a custom filter function. 585 | 586 | The filter name can then be used in argument or option expressions such as 587 | `add ` or `--search=`. 588 | 589 | The filter function will be invoked with the filter value and MUST return a 590 | boolean success value if this filter accepts the given value. 591 | The filter value will be passed by reference, so it can be updated if the 592 | filtering was successful. 593 | 594 | ```php 595 | $tokenizer = new Tokenizer(); 596 | $tokenizer->addFilter('ip', function ($value) { 597 | return filter_var($ip, FILTER_VALIDATE_IP); 598 | }); 599 | $tokenizer->addFilter('lower', function (&$value) { 600 | $value = strtolower($value); 601 | return true; 602 | }); 603 | 604 | $router = new Router($tokenizer); 605 | $router->add('add ', function ($args) { }); 606 | $router->add('--search=', function ($args) { }); 607 | ``` 608 | 609 | ## Install 610 | 611 | The recommended way to install this library is [through Composer](https://getcomposer.org). 612 | [New to Composer?](https://getcomposer.org/doc/00-intro.md) 613 | 614 | This project follows [SemVer](https://semver.org/). 615 | This will install the latest supported version: 616 | 617 | ```bash 618 | $ composer require clue/commander:^1.4 619 | ``` 620 | 621 | See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. 622 | 623 | This project aims to run on any platform and thus does not require any PHP 624 | extensions and supports running on legacy PHP 5.3 through current PHP 8+ and 625 | HHVM. 626 | It's *highly recommended to use PHP 7+* for this project. 627 | 628 | ## Tests 629 | 630 | To run the test suite, you first need to clone this repo and then install all 631 | dependencies [through Composer](https://getcomposer.org): 632 | 633 | ```bash 634 | $ composer install 635 | ``` 636 | 637 | To run the test suite, go to the project root and run: 638 | 639 | ```bash 640 | $ php vendor/bin/phpunit 641 | ``` 642 | 643 | ## License 644 | 645 | This project is released under the permissive [MIT license](LICENSE). 646 | 647 | > Did you know that I offer custom development services and issuing invoices for 648 | sponsorships of releases and for contributions? Contact me (@clue) for details. 649 | 650 | ## More 651 | 652 | * If you want to build an interactive CLI tool, you may want to look into using 653 | [clue/reactphp-stdio](https://github.com/clue/reactphp-stdio) in order to react 654 | to commands from STDIN. 655 | * If you build an interactive CLI tool that reads a command line from STDIN, you 656 | may want to use [clue/arguments](https://github.com/clue/arguments) in 657 | order to split this string up into its individual arguments. 658 | --------------------------------------------------------------------------------