--- /dev/null
+* Route Expression
+
+** Build expression
+
+ There are two ways to build expression: using fluent api and compiling.
+
+*** Fluent API
+
+ Create instance of RouteExpression class and compose expression by chaining parts.
+
+ Example: ~$expr = (new RouteExpression)->optional('/ru')->just('/story/');~
+
+ Available parts:
+
+ 1. ~just($text)~ -- literal text;
+ 2. ~oneOf($choice1, $choice2, ...)~ -- one of given choices;
+ 3. ~oneOfv($choices)~ -- same as above, except it takes array of choices;
+ 4. ~optional($option)~ -- optional text, same as ~oneOf($option, '')~;
+ 5. ~id()~ -- nonempty sequence of digits;
+ 6. ~slug()~ -- nonempty sequence of letters, digits, underscores and dashes.
+
+*** Compilation
+
+ Route expression can be compiled from text representation.
+
+ Example: ~$expr = RouteExpression::compile('[/ru]/story/');~
+
+ Text enclosed in brackets ~[]~ is treated as ~optional~.
+ Text enclosed in braces ~{}~ is treated as ~oneOf~. Choices are separated by ~|~ pipe character.
+ Id and slug are written as ~:id~ and ~:slug~.
+
+** Matching
+
+ #+BEGIN_SRC php
+ bool match($url, &$matches)
+ #+END_SRC
+
+ This method returns ~true~ on successfull match of ~$url~ and matched parts in ~$matches~ array.
+
+ Example: ~RouteExpression::compile('[/ru]/story/')->match('/story/', $matches);~ returns ~true~ and ~$matches~ contains empty string which corresponds to optional part of expression.
+
+** Reversing
+
+ #+BEGIN_SRC php
+ string reverse($matches)
+ #+END_SRC
+
+ This method substitutes values from ~$matches~ array to the expression and returns url.
+
--- /dev/null
+{
+ "name": "andy128k/routeexpression",
+ "description": "Route expression",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Andrey Kutejko",
+ "email": "andy128k@gmail.com"
+ }
+ ],
+ "autoload": {
+ "classmap" : ["src"]
+ },
+ "require-dev": {
+ "phpunit/phpunit": "3.7.*"
+ }
+}
+
--- /dev/null
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"
+ ],
+ "hash": "e93568fdb3ba2b300af261eed15a4b56",
+ "packages": [
+
+ ],
+ "packages-dev": [
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "1.2.11",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "1.2.11"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1.2.11",
+ "reference": "1.2.11",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3",
+ "phpunit/php-file-iterator": ">=1.3.0@stable",
+ "phpunit/php-text-template": ">=1.1.1@stable",
+ "phpunit/php-token-stream": ">=1.1.3@stable"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "3.7.*"
+ },
+ "suggest": {
+ "ext-dom": "*",
+ "ext-xdebug": ">=2.0.5"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "PHP/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "time": "2013-05-23 18:23:24"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "1.3.3",
+ "source": {
+ "type": "git",
+ "url": "git://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "1.3.3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator/zipball/1.3.3",
+ "reference": "1.3.3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "File/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "http://www.phpunit.de/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "time": "2012-10-11 04:44:38"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "1.1.4",
+ "source": {
+ "type": "git",
+ "url": "git://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "1.1.4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/sebastianbergmann/php-text-template/zipball/1.1.4",
+ "reference": "1.1.4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "Text/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "time": "2012-10-31 11:15:28"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "1.0.4",
+ "source": {
+ "type": "git",
+ "url": "git://github.com/sebastianbergmann/php-timer.git",
+ "reference": "1.0.4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/sebastianbergmann/php-timer/zipball/1.0.4",
+ "reference": "1.0.4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "PHP/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "http://www.phpunit.de/",
+ "keywords": [
+ "timer"
+ ],
+ "time": "2012-10-11 04:45:58"
+ },
+ {
+ "name": "phpunit/php-token-stream",
+ "version": "1.1.5",
+ "source": {
+ "type": "git",
+ "url": "git://github.com/sebastianbergmann/php-token-stream.git",
+ "reference": "1.1.5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/sebastianbergmann/php-token-stream/zipball/1.1.5",
+ "reference": "1.1.5",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "PHP/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Wrapper around PHP's tokenizer extension.",
+ "homepage": "http://www.phpunit.de/",
+ "keywords": [
+ "tokenizer"
+ ],
+ "time": "2012-10-11 04:47:14"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "3.7.21",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "3.7.21"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3.7.21",
+ "reference": "3.7.21",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-spl": "*",
+ "php": ">=5.3.3",
+ "phpunit/php-code-coverage": ">=1.2.1,<1.3.0",
+ "phpunit/php-file-iterator": ">=1.3.1",
+ "phpunit/php-text-template": ">=1.1.1",
+ "phpunit/php-timer": ">=1.0.2,<1.1.0",
+ "phpunit/phpunit-mock-objects": ">=1.2.0,<1.3.0",
+ "symfony/yaml": ">=2.0,<3.0"
+ },
+ "require-dev": {
+ "pear-pear/pear": "1.9.4"
+ },
+ "suggest": {
+ "ext-json": "*",
+ "ext-simplexml": "*",
+ "ext-tokenizer": "*",
+ "phpunit/php-invoker": ">=1.1.0,<1.2.0"
+ },
+ "bin": [
+ "composer/bin/phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.7.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "PHPUnit/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ "",
+ "../../symfony/yaml/"
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "http://www.phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "time": "2013-05-23 18:54:29"
+ },
+ {
+ "name": "phpunit/phpunit-mock-objects",
+ "version": "1.2.3",
+ "source": {
+ "type": "git",
+ "url": "git://github.com/sebastianbergmann/phpunit-mock-objects.git",
+ "reference": "1.2.3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/sebastianbergmann/phpunit-mock-objects/archive/1.2.3.zip",
+ "reference": "1.2.3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3",
+ "phpunit/php-text-template": ">=1.1.1@stable"
+ },
+ "suggest": {
+ "ext-soap": "*"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "PHPUnit/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Mock Object library for PHPUnit",
+ "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/",
+ "keywords": [
+ "mock",
+ "xunit"
+ ],
+ "time": "2013-01-13 10:24:48"
+ },
+ {
+ "name": "symfony/yaml",
+ "version": "v2.3.1",
+ "target-dir": "Symfony/Component/Yaml",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/Yaml.git",
+ "reference": "v2.3.1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/Yaml/zipball/v2.3.1",
+ "reference": "v2.3.1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.3-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "Symfony\\Component\\Yaml\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "http://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony Yaml Component",
+ "homepage": "http://symfony.com",
+ "time": "2013-05-10 18:12:13"
+ }
+ ],
+ "aliases": [
+
+ ],
+ "minimum-stability": "stable",
+ "stability-flags": [
+
+ ],
+ "platform": [
+
+ ],
+ "platform-dev": [
+
+ ]
+}
--- /dev/null
+<?php
+
+final class RouteExpression
+{
+ private $expression, $regex;
+
+ public function __construct()
+ {
+ $this->expression = array();
+ $this->regex = '';
+ }
+
+ public function just($text)
+ {
+ if ($text) {
+ $this->expression[] = array('text', $text);
+ $this->regex .= preg_quote($text, '#');
+ }
+ return $this;
+ }
+
+ public function oneOf()
+ {
+ return $this->oneOfv(func_get_args());
+ }
+
+ public function oneOfv($options)
+ {
+ $quoted = array();
+ foreach ($options as $option) {
+ $quoted[] = preg_quote($option, '#');
+ }
+ $this->regex .= '(' . implode('|', $quoted) . ')';
+
+ array_unshift($options, 'oneOf');
+ $this->expression[] = $options;
+ return $this;
+ }
+
+ public function optional($option)
+ {
+ $this->expression[] = array('optional', $option);
+ $this->regex .= '((?:' . preg_quote($option, '#') . ')?)';
+ return $this;
+ }
+
+ public function id()
+ {
+ $this->expression[] = array('id');
+ $this->regex .= '(\d+)';
+ return $this;
+ }
+
+ public function slug()
+ {
+ $this->expression[] = array('slug');
+ $this->regex .= '([\w\_\-]+)';
+ return $this;
+ }
+
+ public function match($url, &$matches)
+ {
+ if (1 == preg_match('#^'.$this->regex.'$#', $url, $matches)) {
+ array_shift($matches);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public function reverse($matches)
+ {
+ $matchesIndex = 0;
+ $url = '';
+ foreach ($this->expression as $part) {
+ switch ($part[0]) {
+ case 'text':
+ $url .= $part[1];
+ break;
+ case 'oneOf':
+ $m = $matches[$matchesIndex++];
+ $url .= $m;
+ break;
+ case 'optional':
+ $m = $matches[$matchesIndex++];
+ if ($m && $m !== $part[1]) {
+ throw new Exception('Wrong optional parameter "'.$m.'". "'.$part[1].'" is expected.');
+ }
+ $url .= $m;
+ break;
+ case 'id':
+ $m = $matches[$matchesIndex++];
+ $url .= $m;
+ break;
+ case 'slug':
+ $m = $matches[$matchesIndex++];
+ $url .= $m;
+ break;
+ }
+ }
+ if ($matchesIndex !== count($matches)) {
+ throw new Exception('Parameters doesn\'t match to url expression.');
+ }
+ return $url;
+ }
+
+ public function __toString()
+ {
+ $str = '';
+ foreach ($this->expression as $part) {
+ switch ($part[0]) {
+ case 'text':
+ $str .= $part[1];
+ break;
+ case 'oneOf':
+ $str .= '{' . implode('|', array_slice($part, 1)) . '}';
+ break;
+ case 'optional':
+ $str .= '[' . $part[1] . ']';
+ break;
+ case 'id':
+ $str .= ':id';
+ break;
+ case 'slug':
+ $str .= ':slug';
+ break;
+ }
+ }
+ return $str;
+ }
+
+ public static function compile($expression)
+ {
+ $state = 0;
+ $text = '';
+ $result = new RouteExpression();
+ for ($i = 0; $i < strlen($expression); ++$i) {
+ $ch = $expression[$i];
+ switch ($state) {
+ case 0:
+ switch ($ch) {
+ case '{':
+ $result->just($text);
+ $state = 1;
+ $text = '';
+ break;
+ case '[':
+ $result->just($text);
+ $state = 2;
+ $text = '';
+ break;
+ case ':':
+ $result->just($text);
+ $state = 3;
+ $text = '';
+ break;
+ default:
+ $text .= $ch;
+ break;
+ }
+ break;
+ case 1:
+ if ($ch === '}') {
+ $result->oneOfv(explode('|', $text));
+ $state = 0;
+ $text = '';
+ } else {
+ $text .= $ch;
+ }
+ break;
+ case 2:
+ if ($ch === ']') {
+ $result->optional($text);
+ $state = 0;
+ $text = '';
+ } else {
+ $text .= $ch;
+ }
+ break;
+ case 3:
+ if (ctype_alpha($ch)) {
+ $text .= $ch;
+ } else {
+ if ($text === 'id') {
+ $result->id();
+ } elseif ($text === 'slug') {
+ $result->slug();
+ } else {
+ throw new Exception('Unsupported route part "'.$text.'".');
+ }
+ $state = 0;
+ $text = $ch;
+ }
+ break;
+ }
+ }
+ if ($state !== 0)
+ throw new Exception('Unexpected end of route expression.');
+ $result->just($text);
+ return $result;
+ }
+}
+
--- /dev/null
+<?php
+
+class RouteExpressionTest extends PHPUnit_Framework_TestCase
+{
+ private function assertMatch($expression, $url, $params=null)
+ {
+ $m = array();
+ $r = $expression->match($url, $m);
+ $this->assertTrue($r);
+ if ($params !== null)
+ $this->assertEquals($m, $params);
+ }
+
+ private function assertNotMatch($expression, $url)
+ {
+ $m = array();
+ $r = $expression->match($url, $m);
+ $this->assertFalse($r);
+ }
+
+ public function testOptional()
+ {
+ $e = (new RouteExpression)->optional('/ru')->just('/story/');
+ $this->assertNotMatch($e, '/story');
+ $this->assertMatch($e, '/story/', array(''));
+ $this->assertNotMatch($e, '/en/story/');
+ $this->assertMatch($e, '/ru/story/', array('/ru'));
+ $this->assertNotMatch($e, '/ru/story');
+ }
+
+ public function testOptionalAndSlug()
+ {
+ $e = (new RouteExpression)->just('/')->optional('ru/')->just('story/')->slug()->just('/');
+ $this->assertNotMatch($e, '/story/wrghsh');
+ $this->assertMatch($e, '/story/srghwrghew/', array('', 'srghwrghew'));
+ $this->assertNotMatch($e, '/en/story/wgwrgewrghwrgh/');
+ $this->assertMatch($e, '/ru/story/wrhwrhr4j4hee/', array('ru/', 'wrhwrhr4j4hee'));
+ $this->assertNotMatch($e, '/ru/story/rewewrhewrhe');
+ }
+
+ public function testOptionalAndId()
+ {
+ $e = (new RouteExpression)->just('/')->optional('ru/')->just('story/')->id()->just('/');
+ $this->assertNotMatch($e, '/story/23');
+ $this->assertMatch($e, '/story/354/', array('', 354));
+ $this->assertMatch($e, '/ru/story/44/', array('ru/', 44));
+ $this->assertNotMatch($e, '/ru/story/-34/');
+ }
+
+ public function testOneOf()
+ {
+ $e = (new RouteExpression)->oneOf('/ru', '/en')->just('/story/');
+ $this->assertNotMatch($e, '/story/');
+ $this->assertMatch($e, '/ru/story/', array('/ru'));
+ $this->assertMatch($e, '/en/story/', array('/en'));
+ $this->assertNotMatch($e, '/fr/story/');
+ }
+
+ public function testOneOfWithEmptyCase()
+ {
+ $e = (new RouteExpression)->oneOf('/ru', '/en', '')->just('/story/');
+ $this->assertMatch($e, '/story/', array(''));
+ $this->assertMatch($e, '/ru/story/', array('/ru'));
+ $this->assertMatch($e, '/en/story/', array('/en'));
+ $this->assertNotMatch($e, '/fr/story/');
+ }
+
+ public function testCompiler()
+ {
+ $this->assertInstanceOf('RouteExpression', RouteExpression::compile('[/ru]/story/'));
+ $this->assertInstanceOf('RouteExpression', RouteExpression::compile('{/ru|/en}/story/'));
+ $this->assertInstanceOf('RouteExpression', RouteExpression::compile('{/ru|/en}/story/:slug/'));
+ $this->assertInstanceOf('RouteExpression', RouteExpression::compile('{/ru|/en}/story/:id/'));
+ }
+
+ public function testReverseOptional()
+ {
+ $e = RouteExpression::compile('[/ru]/story/');
+ $this->assertEquals('/ru/story/', $e->reverse(array('/ru')));
+ $this->assertEquals('/story/', $e->reverse(array('')));
+ }
+
+ public function testReverseOneOfAndId()
+ {
+ $e = RouteExpression::compile('{/ru|/en}/story/:id/');
+ $this->assertEquals('/ru/story/46536/', $e->reverse(array('/ru', 46536)));
+ $this->assertEquals('/en/story/15/', $e->reverse(array('/en', 0xF)));
+ }
+}
+