summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSam Light <sam@lightscale.co.uk>2026-06-10 19:00:32 +0100
committerSam Light <sam@lightscale.co.uk>2026-06-10 19:00:32 +0100
commit5fe7c87967ff29c4a8f03a9186918d8359f4887e (patch)
treedfdd4fdc7a4e96266305f82f5846750ab2efabc9
parent01eac9658c3bc486d2d42a18557fdb82a536348e (diff)
big update
-rw-r--r--composer.json10
-rw-r--r--composer.lock427
-rw-r--r--phpstan.neon5
-rw-r--r--src/Enums/PathSegmentType.php (renamed from src/Enums/SpecialSegment.php)6
-rw-r--r--src/Exceptions/NotFoundException.php12
-rw-r--r--src/Exceptions/UnknownMethodException.php12
-rw-r--r--src/PathSegment.php123
-rw-r--r--src/PathSegmentMatch.php15
-rw-r--r--src/Route.php34
-rw-r--r--src/Router.php71
-rw-r--r--tests/Unit/PathSegmentTest.php215
-rw-r--r--tests/Unit/RouteTest.php48
-rw-r--r--tests/Unit/RouterTest.php90
13 files changed, 942 insertions, 126 deletions
diff --git a/composer.json b/composer.json
index bf0d70b..6ecde7c 100644
--- a/composer.json
+++ b/composer.json
@@ -4,7 +4,9 @@
"type": "library",
"autoload": {
"psr-4": {
- "Lightscale\\Router\\": "src/"
+ "Lightscale\\Router\\": "src/",
+ "Lightscale\\Router\\Test\\": "tests/"
+
}
},
"authors": [
@@ -14,13 +16,15 @@
}
],
"require": {
- "php": ">=8.3"
+ "php": ">=8.3",
+ "psr/http-message": "^2.0"
},
"require-dev": {
"pestphp/pest": "^4.7",
"phpstan/phpstan": "^2.2",
"friendsofphp/php-cs-fixer": "^3.95",
- "ace-of-aces/intellipest": "^1.0"
+ "ace-of-aces/intellipest": "^1.0",
+ "nyholm/psr7": "^1.8"
},
"config": {
"allow-plugins": {
diff --git a/composer.lock b/composer.lock
index e5defa7..707d8d0 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,62 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "1af391e5424ed257200dd8dd28529f5a",
- "packages": [],
+ "content-hash": "1b580d80d8e453e316c8a11cd22f4157",
+ "packages": [
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ }
+ ],
"packages-dev": [
{
"name": "ace-of-aces/intellipest",
@@ -734,23 +788,23 @@
},
{
"name": "friendsofphp/php-cs-fixer",
- "version": "v3.95.2",
+ "version": "v3.95.3",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
- "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1"
+ "reference": "3d681493acc0e93283481b1c63c263737df78687"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a28d88a5e172b27e78d0816992b15a9df3da20f1",
- "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1",
+ "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3d681493acc0e93283481b1c63c263737df78687",
+ "reference": "3d681493acc0e93283481b1c63c263737df78687",
"shasum": ""
},
"require": {
"clue/ndjson-react": "^1.3",
"composer/semver": "^3.4",
"composer/xdebug-handler": "^3.0.5",
- "ergebnis/agent-detector": "^1.1.1",
+ "ergebnis/agent-detector": "^1.2",
"ext-filter": "*",
"ext-hash": "*",
"ext-json": "*",
@@ -767,10 +821,10 @@
"symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
"symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
"symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
- "symfony/polyfill-mbstring": "^1.33",
- "symfony/polyfill-php80": "^1.33",
- "symfony/polyfill-php81": "^1.33",
- "symfony/polyfill-php84": "^1.33",
+ "symfony/polyfill-mbstring": "^1.37",
+ "symfony/polyfill-php80": "^1.37",
+ "symfony/polyfill-php81": "^1.37",
+ "symfony/polyfill-php84": "^1.37",
"symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0",
"symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0"
},
@@ -784,9 +838,9 @@
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
"phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55",
- "symfony/polyfill-php85": "^1.33",
+ "symfony/polyfill-php85": "^1.37",
"symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8",
- "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8"
+ "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11"
},
"suggest": {
"ext-dom": "For handling output formats in XML",
@@ -827,7 +881,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
- "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.2"
+ "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.3"
},
"funding": [
{
@@ -835,7 +889,7 @@
"type": "github"
}
],
- "time": "2026-05-15T09:20:44+00:00"
+ "time": "2026-05-29T20:35:26+00:00"
},
{
"name": "jean85/pretty-package-versions",
@@ -1199,6 +1253,84 @@
"time": "2026-02-16T23:10:27+00:00"
},
{
+ "name": "nyholm/psr7",
+ "version": "1.8.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Nyholm/psr7.git",
+ "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "provide": {
+ "php-http/message-factory-implementation": "1.0",
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "http-interop/http-factory-tests": "^0.9",
+ "php-http/message-factory": "^1.0",
+ "php-http/psr7-integration-tests": "^1.0",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
+ "symfony/error-handler": "^4.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.8-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Nyholm\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com"
+ },
+ {
+ "name": "Martijn van der Ven",
+ "email": "martijn@vanderven.se"
+ }
+ ],
+ "description": "A fast PHP7 implementation of PSR-7",
+ "homepage": "https://tnyholm.se",
+ "keywords": [
+ "psr-17",
+ "psr-7"
+ ],
+ "support": {
+ "issues": "https://github.com/Nyholm/psr7/issues",
+ "source": "https://github.com/Nyholm/psr7/tree/1.8.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Zegnat",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nyholm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-09-09T07:06:30+00:00"
+ },
+ {
"name": "pestphp/pest",
"version": "v4.7.0",
"source": {
@@ -2533,6 +2665,61 @@
"time": "2019-01-08T18:20:26+00:00"
},
{
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
"name": "psr/log",
"version": "3.0.2",
"source": {
@@ -4122,23 +4309,29 @@
},
{
"name": "symfony/console",
- "version": "v8.0.13",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "f200be111431cff4aeae8d086b91180bc4d7efd7"
+ "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/f200be111431cff4aeae8d086b91180bc4d7efd7",
- "reference": "f200be111431cff4aeae8d086b91180bc4d7efd7",
+ "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a",
+ "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a",
"shasum": ""
},
"require": {
- "php": ">=8.4",
+ "php": ">=8.4.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "^1.0",
+ "symfony/polyfill-php85": "^1.32",
"symfony/service-contracts": "^2.5|^3",
- "symfony/string": "^7.4|^8.0"
+ "symfony/string": "^7.4.6|^8.0.6"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<8.1",
+ "symfony/event-dispatcher": "<8.1"
},
"provide": {
"psr/log-implementation": "1.0|2.0|3.0"
@@ -4146,14 +4339,18 @@
"require-dev": {
"psr/log": "^1|^2|^3",
"symfony/config": "^7.4|^8.0",
- "symfony/dependency-injection": "^7.4|^8.0",
- "symfony/event-dispatcher": "^7.4|^8.0",
+ "symfony/dependency-injection": "^8.1",
+ "symfony/event-dispatcher": "^8.1",
+ "symfony/filesystem": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/lock": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
+ "symfony/mime": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0",
+ "symfony/uid": "^7.4|^8.0",
+ "symfony/validator": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"type": "library",
@@ -4188,7 +4385,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v8.0.13"
+ "source": "https://github.com/symfony/console/tree/v8.1.0"
},
"funding": [
{
@@ -4208,7 +4405,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-24T08:59:15+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -4283,20 +4480,21 @@
},
{
"name": "symfony/event-dispatcher",
- "version": "v8.0.9",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f"
+ "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f",
- "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102",
+ "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102",
"shasum": ""
},
"require": {
- "php": ">=8.4",
+ "php": ">=8.4.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/event-dispatcher-contracts": "^2.5|^3"
},
"conflict": {
@@ -4344,7 +4542,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9"
+ "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0"
},
"funding": [
{
@@ -4364,7 +4562,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-18T13:51:42+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -4448,20 +4646,21 @@
},
{
"name": "symfony/filesystem",
- "version": "v8.0.11",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7"
+ "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7",
- "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
+ "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
"shasum": ""
},
"require": {
- "php": ">=8.4",
+ "php": ">=8.4.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
@@ -4494,7 +4693,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v8.0.11"
+ "source": "https://github.com/symfony/filesystem/tree/v8.1.0"
},
"funding": [
{
@@ -4514,24 +4713,24 @@
"type": "tidelift"
}
],
- "time": "2026-05-11T16:39:47+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/finder",
- "version": "v8.0.8",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "8da41214757b87d97f181e3d14a4179286151007"
+ "reference": "58d2e767a66052c1487356f953445634a8194c64"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007",
- "reference": "8da41214757b87d97f181e3d14a4179286151007",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64",
+ "reference": "58d2e767a66052c1487356f953445634a8194c64",
"shasum": ""
},
"require": {
- "php": ">=8.4"
+ "php": ">=8.4.1"
},
"require-dev": {
"symfony/filesystem": "^7.4|^8.0"
@@ -4562,7 +4761,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v8.0.8"
+ "source": "https://github.com/symfony/finder/tree/v8.1.0"
},
"funding": [
{
@@ -4582,24 +4781,24 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T15:14:47+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/options-resolver",
- "version": "v8.0.8",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
- "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8"
+ "reference": "88f9c561f678a02d54b897014049fa839e33ff82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8",
- "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
+ "reference": "88f9c561f678a02d54b897014049fa839e33ff82",
"shasum": ""
},
"require": {
- "php": ">=8.4",
+ "php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
@@ -4633,7 +4832,7 @@
"options"
],
"support": {
- "source": "https://github.com/symfony/options-resolver/tree/v8.0.8"
+ "source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
},
"funding": [
{
@@ -4653,7 +4852,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T15:14:47+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -5235,21 +5434,101 @@
"time": "2026-05-26T12:51:13+00:00"
},
{
+ "name": "symfony/polyfill-php85",
+ "version": "v1.38.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php85.git",
+ "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
+ "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php85\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-26T02:25:22+00:00"
+ },
+ {
"name": "symfony/process",
- "version": "v8.0.13",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4"
+ "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/4f23d9c7637ead9ed19f697fe93cb87fd9b379d4",
- "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4",
+ "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5",
+ "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5",
"shasum": ""
},
"require": {
- "php": ">=8.4"
+ "php": ">=8.4.1"
},
"type": "library",
"autoload": {
@@ -5277,7 +5556,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v8.0.13"
+ "source": "https://github.com/symfony/process/tree/v8.1.0"
},
"funding": [
{
@@ -5297,7 +5576,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-23T18:05:53+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/service-contracts",
@@ -5388,20 +5667,20 @@
},
{
"name": "symfony/stopwatch",
- "version": "v8.0.8",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
- "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3"
+ "reference": "21c07b026905d596e8379caeb115d87aa479499d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3",
- "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d",
+ "reference": "21c07b026905d596e8379caeb115d87aa479499d",
"shasum": ""
},
"require": {
- "php": ">=8.4",
+ "php": ">=8.4.1",
"symfony/service-contracts": "^2.5|^3"
},
"type": "library",
@@ -5430,7 +5709,7 @@
"description": "Provides a way to profile code",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/stopwatch/tree/v8.0.8"
+ "source": "https://github.com/symfony/stopwatch/tree/v8.1.0"
},
"funding": [
{
@@ -5450,24 +5729,24 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T15:14:47+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/string",
- "version": "v8.0.13",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc"
+ "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/f2e3e4d33579350d1b12001ef2872f86b27ed3dc",
- "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc",
+ "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9",
+ "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9",
"shasum": ""
},
"require": {
- "php": ">=8.4",
+ "php": ">=8.4.1",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-intl-grapheme": "^1.33",
"symfony/polyfill-intl-normalizer": "^1.0",
@@ -5520,7 +5799,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v8.0.13"
+ "source": "https://github.com/symfony/string/tree/v8.1.0"
},
"funding": [
{
@@ -5540,7 +5819,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-23T18:05:53+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "ta-tikoma/phpunit-architecture-test",
diff --git a/phpstan.neon b/phpstan.neon
index 2f87fc7..6e02a9e 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -8,3 +8,8 @@ parameters:
message: '#Call to an undefined method Pest\\PendingCalls\\TestCall::[a-zA-Z0-9\\_]+#'
path: tests
reportUnmatched: false
+ -
+ message: '#Access to an undefined property Pest\\(Mixins\\)?Expectation#'
+ identifier: property.notFound
+ path: tests
+ reportUnmatched: false
diff --git a/src/Enums/SpecialSegment.php b/src/Enums/PathSegmentType.php
index 6b451c5..6547842 100644
--- a/src/Enums/SpecialSegment.php
+++ b/src/Enums/PathSegmentType.php
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Lightscale\Router\Enums;
-enum SpecialSegment: string
+enum PathSegmentType
{
- case Root = '<root>';
+ case Raw;
+ case Root;
+ case Parameter;
}
diff --git a/src/Exceptions/NotFoundException.php b/src/Exceptions/NotFoundException.php
new file mode 100644
index 0000000..306ba15
--- /dev/null
+++ b/src/Exceptions/NotFoundException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Lightscale\Router\Exceptions;
+
+use RuntimeException;
+
+class NotFoundException extends RuntimeException
+{
+ protected $message = 'Could not find request route';
+}
diff --git a/src/Exceptions/UnknownMethodException.php b/src/Exceptions/UnknownMethodException.php
new file mode 100644
index 0000000..220cf42
--- /dev/null
+++ b/src/Exceptions/UnknownMethodException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Lightscale\Router\Exceptions;
+
+use RuntimeException;
+
+class UnknownMethodException extends RuntimeException
+{
+ protected $message = 'Unknown HTTP request method';
+}
diff --git a/src/PathSegment.php b/src/PathSegment.php
index 7f24660..5333d17 100644
--- a/src/PathSegment.php
+++ b/src/PathSegment.php
@@ -5,37 +5,66 @@ declare(strict_types=1);
namespace Lightscale\Router;
use Lightscale\Router\Enums\HttpMethod;
-use Lightscale\Router\Enums\SpecialSegment;
+use Lightscale\Router\Enums\PathSegmentType;
class PathSegment
{
private const MAX_ANCESTORY_DEPTH = 100;
- protected string $content;
-
- protected ?SpecialSegment $specialSegment = null;
-
/** @var array<string, self> */
protected array $children;
- /** @var array<value-of<HttpMethod>, Route[]> */
+ /** @var array<value-of<HttpMethod>, Route> */
protected array $routes = [];
+ protected ?self $parent = null;
+
public function __construct(
- SpecialSegment|string $content,
- protected ?self $parent = null,
+ protected ?string $value = null,
+ protected PathSegmentType $type = PathSegmentType::Raw,
) {
- if ($content instanceof SpecialSegment) {
- $this->content = $content->value;
- $this->specialSegment = $content;
- } else {
- $this->content = $content;
+ if (PathSegmentType::Root === $type) {
+ $this->value = null;
}
}
public function getContent(): string
{
- return $this->content;
+ return match ($this->type) {
+ PathSegmentType::Root => '',
+ default => $this->value ?? '',
+ };
+ }
+
+ public function getValue(): ?string
+ {
+ return $this->value;
+ }
+
+ private static function createKey(
+ ?string $value,
+ PathSegmentType $type,
+ ): string {
+ return match ($type) {
+ PathSegmentType::Raw => $value ?? '',
+ PathSegmentType::Root => '<root>',
+ PathSegmentType::Parameter => '<parameter>',
+ };
+ }
+
+ public function getKey(): string
+ {
+ return self::createKey($this->value, $this->type);
+ }
+
+ public function getType(): PathSegmentType
+ {
+ return $this->type;
+ }
+
+ public function setParent(?self $segment): void
+ {
+ $this->parent = $segment;
}
public function getParent(): ?self
@@ -72,7 +101,8 @@ class PathSegment
public function addChild(self $segment): void
{
- $this->children[$segment->getContent()] = $segment;
+ $segment->setParent($this);
+ $this->children[$segment->getKey()] = $segment;
}
/** @return array<string, self> */
@@ -81,7 +111,68 @@ class PathSegment
return $this->children;
}
- public function addRoute(): void
+ public function getChildrenCount(): int
+ {
+ return count($this->children);
+ }
+
+ public function findChild(
+ ?string $value = null,
+ ): ?PathSegment {
+ $key = self::createKey($value, PathSegmentType::Raw);
+ $seg = $this->children[$key] ?? null;
+
+ if ($seg instanceof self) {
+ return $seg;
+ }
+
+ $key = self::createKey($value, PathSegmentType::Parameter);
+ $seg = $this->children[$key] ?? null;
+
+ return $seg;
+ }
+
+ public function child(
+ ?string $value = null,
+ PathSegmentType $type = PathSegmentType::Raw,
+ ): self {
+ $key = self::createKey($value, $type);
+ $seg = $this->children[$key] ?? null;
+
+ if (null === $seg) {
+ $seg = new PathSegment($value, $type);
+ }
+
+ $this->addChild($seg);
+
+ return $seg;
+ }
+
+ public function getPath(): string
+ {
+ $parts = array_map(
+ fn ($seg) => $seg->getContent(),
+ $this->getAncestorsAndSelf()
+ );
+
+ return implode('/', $parts);
+ }
+
+ public function addRoute(Route $route): void
+ {
+ $route->setSegment($this);
+ $method = $route->getMethod()->value;
+ $this->routes[$method] = $route;
+ }
+
+ public function getRoute(HttpMethod $method): ?Route
+ {
+ return $this->routes[$method->value] ?? null;
+ }
+
+ /** @return array<value-of<HttpMethod>, Route> */
+ public function getRoutes(): array
{
+ return $this->routes;
}
}
diff --git a/src/PathSegmentMatch.php b/src/PathSegmentMatch.php
new file mode 100644
index 0000000..738b4a5
--- /dev/null
+++ b/src/PathSegmentMatch.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Lightscale\Router;
+
+readonly class PathSegmentMatch
+{
+ /** @param array<string, string> $parameters */
+ public function __construct(
+ public PathSegment $segment,
+ public array $parameters,
+ ) {
+ }
+}
diff --git a/src/Route.php b/src/Route.php
index 1a9ad03..aeb3f66 100644
--- a/src/Route.php
+++ b/src/Route.php
@@ -6,16 +6,48 @@ namespace Lightscale\Router;
use Closure;
use Lightscale\Router\Enums\HttpMethod;
+use Psr\Http\Message\RequestInterface;
class Route
{
protected Closure $handler;
+ protected PathSegment $segment;
+
public function __construct(
- protected PathSegment $segment,
protected HttpMethod $method,
callable $handler,
+ ?PathSegment $segment = null,
) {
$this->handler = Closure::fromCallable($handler);
+
+ if (null !== $segment) {
+ $this->segment = $segment;
+ }
+ }
+
+ public function __invoke(RequestInterface $request): mixed
+ {
+ return ($this->handler)($request);
+ }
+
+ public function getMethod(): HttpMethod
+ {
+ return $this->method;
+ }
+
+ public function setSegment(PathSegment $segment): void
+ {
+ $this->segment = $segment;
+ }
+
+ public function getSegment(): PathSegment
+ {
+ return $this->segment;
+ }
+
+ public function getPath(): string
+ {
+ return $this->segment->getPath();
}
}
diff --git a/src/Router.php b/src/Router.php
index efcccfa..f666fe5 100644
--- a/src/Router.php
+++ b/src/Router.php
@@ -4,6 +4,77 @@ declare(strict_types=1);
namespace Lightscale\Router;
+use Lightscale\Router\Enums\HttpMethod;
+use Lightscale\Router\Enums\PathSegmentType;
+use Lightscale\Router\Exceptions\NotFoundException;
+use Lightscale\Router\Exceptions\UnknownMethodException;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+
class Router
{
+ private PathSegment $root;
+
+ public function __construct()
+ {
+ $this->root = new PathSegment(type: PathSegmentType::Root);
+ }
+
+ public function root(): PathSegment
+ {
+ return $this->root;
+ }
+
+ /** @return string[] */
+ private function splitPath(string $path): array
+ {
+ $split = explode('/', rtrim($path, '/'));
+ array_shift($split);
+
+ return $split;
+ }
+
+ public function findSegment(string $path): ?PathSegmentMatch
+ {
+ $pathSplit = $this->splitPath($path);
+ $seg = $this->root();
+ $params = [];
+
+ while (($v = array_shift($pathSplit)) !== null && null !== $seg) {
+ $seg = $seg->findChild($v);
+
+ if (PathSegmentType::Parameter === $seg?->getType()) {
+ $params[$seg->getValue() ?? ''] = $v;
+ }
+ }
+
+ return null === $seg ? null : new PathSegmentMatch(
+ segment: $seg,
+ parameters: $params
+ );
+ }
+
+ public function dispatch(RequestInterface $request): ResponseInterface
+ {
+ $uri = $request->getUri();
+ $match = $this->findSegment($uri->getPath());
+
+ if (null === $match) {
+ throw new NotFoundException();
+ }
+
+ $segment = $match->segment;
+ $method = $request->getMethod();
+ $method = (
+ HttpMethod::tryFrom(strtolower($method)) ??
+ throw new UnknownMethodException()
+ );
+ $route = $segment->getRoute($method);
+
+ if (null === $route) {
+ throw new NotFoundException();
+ }
+
+ return $route($request);
+ }
}
diff --git a/tests/Unit/PathSegmentTest.php b/tests/Unit/PathSegmentTest.php
index 1d19e0e..c537c38 100644
--- a/tests/Unit/PathSegmentTest.php
+++ b/tests/Unit/PathSegmentTest.php
@@ -2,7 +2,10 @@
declare(strict_types=1);
+use Lightscale\Router\Enums\HttpMethod;
+use Lightscale\Router\Enums\PathSegmentType;
use Lightscale\Router\PathSegment;
+use Lightscale\Router\Route;
it('initializes')
->expect(fn () => new PathSegment('test'))
@@ -12,11 +15,60 @@ it('can get the content')
->expect(fn () => (new PathSegment('test'))->getContent())
->toBe('test');
-it('can get the parent')
- ->expect(fn () => (new PathSegment(
- 'test1',
- new PathSegment('test2')
- ))->getParent())
+it('can be root')
+ ->expect((new PathSegment(type: PathSegmentType::Root))->getType())
+ ->toBe(PathSegmentType::Root);
+
+it('nulls value when type root')
+ ->expect(fn () => (new PathSegment('testing', PathSegmentType::Root))->getValue())
+ ->toBeNull();
+
+it('gets value for type :dataset', function (PathSegmentType $type, ?string $res) {
+ $seg = new PathSegment($v = 'testing', $type);
+ expect($seg->getValue())->toBe($res);
+})->with([
+ ($t = PathSegmentType::Raw)->name => [$t, 'testing'],
+ ($t = PathSegmentType::Root)->name => [$t, null],
+ ($t = PathSegmentType::Parameter)->name => [$t, 'testing'],
+]);
+
+it('gets key for type :dataset', function (PathSegmentType $type, ?string $res) {
+ $seg = new PathSegment($v = 'testing', $type);
+ expect($seg->getKey())->toBe($res);
+})->with([
+ ($t = PathSegmentType::Raw)->name => [$t, 'testing'],
+ ($t = PathSegmentType::Root)->name => [$t, '<root>'],
+ ($t = PathSegmentType::Parameter)->name => [$t, '<parameter>'],
+]);
+
+it('gets type :dataset', function (PathSegmentType $type) {
+ $seg = new PathSegment($v = 'testing', $type);
+ expect($seg->getType())->toBe($type);
+})->with(function (): Generator {
+ foreach (PathSegmentType::cases() as $t) {
+ yield $t->name => $t;
+ }
+});
+
+it('defaults type to Raw')
+ ->expect(fn () => (new PathSegment('testing'))->getType())
+ ->toBe(PathSegmentType::Raw);
+
+$testChain = function () {
+ $root = new PathSegment(type: PathSegmentType::Root);
+ $seg1 = new PathSegment('test1');
+ $seg2 = new PathSegment('test2');
+ $seg3 = new PathSegment('test3');
+
+ $seg3->setParent($seg2);
+ $seg2->setParent($seg1);
+ $seg1->setParent($root);
+
+ return $seg3;
+};
+
+it('can have a parent')
+ ->expect(fn () => $testChain()->getParent())
->toBeInstanceOf(PathSegment::class)
->getContent()->toBe('test2');
@@ -24,45 +76,154 @@ it('has null parent by default')
->expect(fn () => (new PathSegment('test'))->getParent())
->toBeNull();
-$testChain = fn () => new PathSegment(
- 'test3',
- new PathSegment(
- 'test2',
- new PathSegment(
- 'test1'
- )
- )
-);
-
it('can get all ancestors')
->expect(fn () => $testChain()->getAncestors())
->toBeArray()
->toContainOnlyInstancesOf(PathSegment::class)
- ->toHaveCount(2);
+ ->toHaveCount(3);
it('order all ancestors root first')
->expect(fn () => $testChain()->getAncestors())
- ->{0}->getContent()->toBe('test1')
- ->{1}->getContent()->toBe('test2');
+ ->{0}->getContent()->toBe('')
+ ->{0}->getType()->toBe(PathSegmentType::Root)
+ ->{1}->getContent()->toBe('test1')
+ ->{2}->getContent()->toBe('test2');
it('can get all ancestors and self')
->expect(fn () => $testChain()->getAncestorsAndSelf())
->toBeArray()
->toContainOnlyInstancesOf(PathSegment::class)
- ->toHaveCount(3);
+ ->toHaveCount(4);
it('order all ancestors and self root first')
->expect(fn () => $testChain()->getAncestorsAndSelf())
- ->{0}->getContent()->toBe('test1')
- ->{1}->getContent()->toBe('test2')
- ->{2}->getContent()->toBe('test3');
+ ->{0}->getContent()->toBe('')
+ ->{0}->getType()->toBe(PathSegmentType::Root)
+ ->{1}->getContent()->toBe('test1')
+ ->{2}->getContent()->toBe('test2')
+ ->{3}->getContent()->toBe('test3');
+
+$makeWithChildren = function () {
+ $seg = new PathSegment('test1');
+ $seg->addChild(new PathSegment('test2'));
+ $seg->addChild(new PathSegment('test3'));
+
+ return $seg;
+};
+
+it('can have children')
+ ->expect(fn () => $makeWithChildren()->getChildren())
+ ->toBeArray()
+ ->toContainOnlyInstancesOf(PathSegment::class)
+ ->{'test2'}->getContent()->toBe('test2')
+ ->{'test3'}->getContent()->toBe('test3');
-it('can have children')->todo();
+it('when adding child sets its parent')
+ ->expect(fn () => $makeWithChildren()->getChildren())
+ ->{'test2'}->getParent()->toBeInstanceOf(PathSegment::class);
-it('can build full path')->todo();
+it('gets the children count')
+ ->expect(fn () => $makeWithChildren()->getChildrenCount())
+ ->toBe(2);
-it('can have routes')->todo();
+it('can build full path')
+ ->expect(fn () => $testChain()->getPath())
+ ->toBe('/test1/test2/test3');
-it('can get routes by method')->todo();
+it('creates new child and returns')
+ ->expect(fn () => (new PathSegment('test1'))->child('test2'))
+ ->toBeInstanceOf(PathSegment::class)
+ ->getContent()->toBe('test2');
-it('can get routes for all methods')->todo();
+it('creates new child added to children')
+ ->expect(function () {
+ $seg = new PathSegment('test1');
+ $seg->child('test2');
+
+ return $seg->getChildren();
+ })
+ ->toHaveCount(1);
+
+it('does not add same child twice', function () {
+ $seg = new PathSegment('test1');
+ $seg2 = $seg->child('test2');
+ $seg3 = $seg->child('test2');
+
+ expect($seg2)->toBe($seg3);
+ expect($seg->getChildrenCount())->toBe(1);
+});
+
+it('can get a raw segment child', function () {
+ $seg = new PathSegment('test1');
+ $c1 = $seg->child('test2');
+ $c2 = $seg->child('test3');
+
+ expect($seg->findChild('test2'))->toBe($c1);
+ expect($seg->findChild('test3'))->toBe($c2);
+});
+
+it('can get a parameter segment child', function () {
+ $seg = new PathSegment('test1');
+ $c1 = $seg->child('test2');
+ $c2 = $seg->child('test3', PathSegmentType::Parameter);
+
+ expect($seg->findChild('test2'))->toBe($c1);
+ expect($seg->findChild('test4'))->toBe($c2);
+});
+
+it('sets route segment when added', function () {
+ $seg = new PathSegment('test1');
+ $seg->addRoute($r = new Route(
+ HttpMethod::Get,
+ fn () => null
+ ));
+
+ expect($r->getSegment())->toBe($seg);
+});
+
+it('can have routes', function () {
+ $seg = new PathSegment('test');
+ $seg->addRoute($r1 = new Route(
+ HttpMethod::Get,
+ fn () => null,
+ ));
+ $seg->addRoute($r2 = new Route(
+ HttpMethod::Post,
+ fn () => null,
+ ));
+
+ expect($seg->getRoutes())
+ ->toHaveCount(2)
+ ->{HttpMethod::Get->value}->toBe($r1)
+ ->{HttpMethod::Post->value}->toBe($r2);
+});
+
+it('overwrites existing routes', function () {
+ $seg = new PathSegment('test');
+ $seg->addRoute($r1 = new Route(
+ HttpMethod::Get,
+ fn () => null,
+ ));
+ $seg->addRoute($r2 = new Route(
+ HttpMethod::Get,
+ fn () => null,
+ ));
+
+ expect($seg->getRoutes())
+ ->toHaveCount(1)
+ ->{HttpMethod::Get->value}->toBe($r2);
+});
+
+it('can get route by method', function () {
+ $seg = new PathSegment('test');
+ $seg->addRoute($r = new Route(
+ HttpMethod::Get,
+ fn () => null,
+ ));
+
+ expect($seg->getRoute(HttpMethod::Get))->toBe($r);
+});
+
+it('gets null with route method that doesn\'t exist')
+ ->expect(fn () => (new PathSegment('test'))->getRoute(HttpMethod::Get))
+ ->toBeNull();
diff --git a/tests/Unit/RouteTest.php b/tests/Unit/RouteTest.php
index 0414649..104401b 100644
--- a/tests/Unit/RouteTest.php
+++ b/tests/Unit/RouteTest.php
@@ -3,14 +3,56 @@
declare(strict_types=1);
use Lightscale\Router\Enums\HttpMethod;
-use Lightscale\Router\Enums\SpecialSegment;
+use Lightscale\Router\Enums\PathSegmentType;
use Lightscale\Router\PathSegment;
use Lightscale\Router\Route;
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Psr\Http\Message\RequestInterface;
it('initializes')
->expect(fn () => new Route(
- new PathSegment(SpecialSegment::Root),
HttpMethod::Get,
- fn () => null
+ fn () => null,
+ new PathSegment(type: PathSegmentType::Root),
))
->toBeInstanceOf(Route::class);
+
+it('gets segment')
+ ->expect(fn () => (new Route(
+ HttpMethod::Get,
+ fn () => null,
+ new PathSegment(type: PathSegmentType::Root),
+ ))->getSegment())
+ ->toBeInstanceOf(PathSegment::class);
+
+it('throws getting segment without being set', function () {
+ $r = new Route(HttpMethod::Get, fn () => null);
+ $r->getSegment();
+})->throws(Error::class);
+
+it('can set segment')
+ ->expect(function () {
+ $r = new Route(HttpMethod::Get, fn () => null);
+ $r->setSegment(new PathSegment('test'));
+
+ return $r->getSegment();
+ })
+ ->toBeInstanceOf(PathSegment::class)
+ ->getValue()->toBe('test');
+
+it('gets method')
+ ->expect(fn () => (new Route(
+ HttpMethod::Get,
+ fn () => null,
+ ))->getMethod())
+ ->toBe(HttpMethod::Get);
+
+it('can call handler')
+ ->expect(fn () => (new Route(
+ HttpMethod::Get,
+ fn (RequestInterface $req) => $req->getUri()->getPath()
+ ))(
+ (new Psr17Factory())->createRequest(HttpMethod::Get->value, '/testing')
+ )
+ )
+ ->toBe('/testing');
diff --git a/tests/Unit/RouterTest.php b/tests/Unit/RouterTest.php
new file mode 100644
index 0000000..5d08441
--- /dev/null
+++ b/tests/Unit/RouterTest.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+use Lightscale\Router\Enums\PathSegmentType;
+use Lightscale\Router\PathSegment;
+use Lightscale\Router\Router;
+
+it('initializes')
+ ->expect(fn () => new Router())
+ ->toBeInstanceOf(Router::class);
+
+it('has root')
+ ->expect(fn () => (new Router())->root())
+ ->toBeInstanceOf(PathSegment::class)
+ ->getType()->toBe(PathSegmentType::Root);
+
+it('finds root segment with :dataset path', function (string $path) {
+ $router = new Router();
+ expect($router->findSegment($path))->segment->toBe($router->root());
+})->with(['', '/']);
+
+it('finds segment one level', function () {
+ $router = new Router();
+ $c1 = $router->root()->child($p1 = 'test1');
+ $c2 = $router->root()->child($p2 = 'test2');
+
+ expect($router->findSegment("/{$p1}"))->segment->toBe($c1);
+ expect($router->findSegment("/{$p2}"))->segment->toBe($c2);
+});
+
+it('finds segment one level parameter', function () {
+ $router = new Router();
+ $c1 = $router->root()->child('test2', PathSegmentType::Parameter);
+
+ expect($router->findSegment('/anything'))
+ ->segment->toBe($c1)
+ ->parameters->{'test2'}->toBe('anything');
+});
+
+it('finds segment one falls back to parameter', function () {
+ $router = new Router();
+ $c1 = $router->root()->child($p1 = 'test1');
+ $c2 = $router->root()->child('test2', PathSegmentType::Parameter);
+
+ expect($router->findSegment("/{$p1}"))->segment->toBe($c1);
+ expect($router->findSegment('/anything'))
+ ->segment->toBe($c2)
+ ->parameters->{'test2'}->toBe('anything');
+});
+
+it('finds segments in complex routes', function () {
+ $router = new Router();
+
+ $r = $router->root();
+
+ $s1 = $r->child('level1-1')->child('level2-1');
+ $s2 = $r->child('level1-1')->child('level2-2');
+ $s3 = $r->child('level1-2')->child('level2-3');
+ $s4 = $r->child('level1-1')->child('level2-2')->child('level3-1');
+ $s5 = $r->child('level1-1');
+ $s6 = $r->child('level1-3')->child('level2-4', PathSegmentType::Parameter);
+ $s7 = $s6->child('level3-2');
+ $s8 = $s6->child('level3-3');
+
+ expect($router->findSegment('/level1-1')?->segment)->toBe($s5);
+ expect($router->findSegment('/level1-2/level2-3')?->segment)->toBe($s3);
+ expect($router->findSegment('/level1-1/level2-2/level3-1')?->segment)->toBe($s4);
+ expect($router->findSegment('/level1-1/level2-1')?->segment)->toBe($s1);
+ expect($router->findSegment('/level1-1/level2-2')?->segment)->toBe($s2);
+ expect($router->findSegment('/level1-3/param1'))
+ ->segment->toBe($s6)
+ ->parameters->{'level2-4'}->toBe('param1');
+ expect($router->findSegment('/level1-3/param2/level3-2'))
+ ->segment->toBe($s7)
+ ->parameters->{'level2-4'}->toBe('param2');
+ expect($router->findSegment('/level1-3/param3/level3-3'))
+ ->segment->toBe($s8)
+ ->parameters->{'level2-4'}->toBe('param3');
+});
+
+it('return null when segment not found', function () {
+ $router = new Router();
+ $r = $router->root();
+ $r->child('level1-1')->child('level2-1');
+ $r->child('level1-1')->child('level2-2');
+ $r->child('level1-2')->child('level2-3');
+
+ expect($router->findSegment('/testing/testing'))->toBeNull();
+});