From a946a936b0f55b443d50ef18ea6c24646510eb37 Mon Sep 17 00:00:00 2001 From: Andrey Kutejko Date: Sat, 22 Jun 2013 10:54:38 +0300 Subject: [PATCH 1/1] initial --- .gitignore | 2 + README.org | 49 +++++ composer.json | 18 ++ composer.lock | 429 ++++++++++++++++++++++++++++++++++++++++ src/routeexpression.php | 203 +++++++++++++++++++ t/ExpressionTest.php | 90 +++++++++ 6 files changed, 791 insertions(+) create mode 100644 .gitignore create mode 100644 README.org create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 src/routeexpression.php create mode 100644 t/ExpressionTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcac4a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor + diff --git a/README.org b/README.org new file mode 100644 index 0000000..48a55dd --- /dev/null +++ b/README.org @@ -0,0 +1,49 @@ +* 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. + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..48e3119 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "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.*" + } +} + diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..a7b1197 --- /dev/null +++ b/composer.lock @@ -0,0 +1,429 @@ +{ + "_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": [ + + ] +} diff --git a/src/routeexpression.php b/src/routeexpression.php new file mode 100644 index 0000000..2f6a4ad --- /dev/null +++ b/src/routeexpression.php @@ -0,0 +1,203 @@ +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; + } +} + diff --git a/t/ExpressionTest.php b/t/ExpressionTest.php new file mode 100644 index 0000000..c634708 --- /dev/null +++ b/t/ExpressionTest.php @@ -0,0 +1,90 @@ +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))); + } +} + -- 2.49.0