From 439bca7c58bee081198cc26ff47d59d9ec6e726e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Runar=20J=C3=B8rgensen?= Date: Sun, 8 Oct 2023 22:00:49 +0200 Subject: [PATCH] #9 - Adds linter that enforces kebab-casing for urls and url prefixes. --- src/Linters/RouteUrlsUsesKebabCasing.php | 137 ++++++++++++++ src/Presets/IndentPreset.php | 2 + .../Linters/RouteUrlsUsesKebabCasingTest.php | 170 ++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 src/Linters/RouteUrlsUsesKebabCasing.php create mode 100644 tests/Linters/RouteUrlsUsesKebabCasingTest.php diff --git a/src/Linters/RouteUrlsUsesKebabCasing.php b/src/Linters/RouteUrlsUsesKebabCasing.php new file mode 100644 index 0000000..253189d --- /dev/null +++ b/src/Linters/RouteUrlsUsesKebabCasing.php @@ -0,0 +1,137 @@ +hasMatchForRegularRouteEntry($node)) { + return true; + } + + if ($this->hasMatchForRouteGroup($node)) { + return true; + } + + return false; + }); + + $traverser->addVisitor($visitor); + $traverser->traverse($parser->parse($this->code)); + + return $visitor->getFoundNodes(); + } + + private function hasMatchForRegularRouteEntry(mixed $node): bool + { + if ($node instanceof Node\Expr\MethodCall + && in_array($node->name->name, self::ROUTE_METHOD_NAMES, true) + && $this->routeClassIsTheStaticRootNode($node) + && isset($node->args[0]->value->value) + && $this->stringIsNotKebabCased($node->args[0]->value->value) + ) { + return true; + } + + return $node instanceof Node\Expr\StaticCall + && ($node->class instanceof Node\Name && $node->class->toString() === 'Route') + && in_array($node->name->name, self::ROUTE_METHOD_NAMES, true) + && isset($node->args[0]->value->value) + && $this->stringIsNotKebabCased($node->args[0]->value->value); + } + + private function hasMatchForRouteGroup(mixed $node): bool + { + // Handles Route::prefix case + if ($node instanceof Node\Expr\StaticCall + && ($node->class instanceof Node\Name && $node->class->toString() === 'Route') + && $node->name->name === 'prefix' + && count($node->args) > 0 + && $node->args[0]->value instanceof Node\Scalar\String_ + && $this->stringIsNotKebabCased($node->args[0]->value->value)) { + return true; + } + + // Handles case where "prefix" is a chained method call of Route:: + if ($node instanceof Node\Expr\MethodCall + && $node->name->name === 'prefix' + && $this->routeClassIsTheStaticRootNode($node) + && count($node->args) === 1 + && $node->args[0]->value instanceof Node\Scalar\String_ + && $this->stringIsNotKebabCased($node->args[0]->value->value) + ) { + return true; + } + + // Handles case where prefix is defined in the group array (Route::group(['prefix' => ...], ...) + if ($node instanceof Node\Expr\StaticCall + && ($node->class instanceof Node\Name && $node->class->toString() === 'Route') + && $node->name->name === 'group') { + if ($node->args[0]->value instanceof Node\Expr\Array_) { + $items = array_values(array_filter($node->args[0]->value->items, function (Node\Expr\ArrayItem $item) { + return $item->key->value === 'prefix'; + })); + + if (count($items) > 0 and $this->stringIsNotKebabCased($items[0]->value->value)) { + return true; + } + } + } + + return false; + } + + private function routeClassIsTheStaticRootNode(Node\Expr\MethodCall $node): bool + { + $rootNode = $this->recursivelyGetRootStaticNode($node); + $rootName = $rootNode->class->parts[0] ?? ''; + + return $rootName === 'Route'; + } + + private function recursivelyGetRootStaticNode(Node\Expr\MethodCall|Node\Expr\StaticCall $node): Node\Expr\StaticCall + { + if ($node instanceof Node\Expr\StaticCall) { + return $node; + } + + return $this->recursivelyGetRootStaticNode($node->var); + } + + public function stringIsNotKebabCased(string $nodeValue): bool + { + $value = $nodeValue; + + if (!ctype_lower($value)) { + $value = preg_replace('/\s+/u', '', ucwords($value)); + $value = preg_replace('/(.)(?=[A-Z])/u', '$1-', $value); + $value = mb_strtolower($value, 'UTF-8'); + } + + return $nodeValue !== $value; + } +} diff --git a/src/Presets/IndentPreset.php b/src/Presets/IndentPreset.php index cb3ebf9..f24ae15 100644 --- a/src/Presets/IndentPreset.php +++ b/src/Presets/IndentPreset.php @@ -8,6 +8,7 @@ use Indent\LaravelLinter\Linters\NoCompact; use Indent\LaravelLinter\Linters\NoDump; use Indent\LaravelLinter\Linters\NoStringInterpolationWithoutBraces; +use Indent\LaravelLinter\Linters\RouteUrlsUsesKebabCasing; use Indent\LaravelLinter\Linters\UseConfigOverEnv; use Indent\LaravelLinter\Linters\ValidRouteStructure; use Tighten\TLint\Linters\ApplyMiddlewareInRoutes; @@ -42,6 +43,7 @@ public function getLinters(): array NoStringInterpolationWithoutBraces::class, QualifiedNamesOnlyForClassName::class, RemoveLeadingSlashNamespaces::class, + RouteUrlsUsesKebabCasing::class, ControllerHasCorrectOrderForRestMethods::class, SpaceAfterBladeDirectives::class, SpacesAroundBladeRenderContent::class, diff --git a/tests/Linters/RouteUrlsUsesKebabCasingTest.php b/tests/Linters/RouteUrlsUsesKebabCasingTest.php new file mode 100644 index 0000000..e9e8b05 --- /dev/null +++ b/tests/Linters/RouteUrlsUsesKebabCasingTest.php @@ -0,0 +1,170 @@ +name('newPassword.create'); + +Route::post('new-password', [NewPasswordController::class, 'store'])->name('newPassword.store'); + +Route::get('articleCategory', [ArticleCategoryController::class, 'index'])->name('articleCategory.index'); +TEST; + + $lints = (new TLint)->lint(new RouteUrlsUsesKebabCasing($file)); + + $this->assertSame(2, count($lints)); + $this->assertEquals(3, $lints[0]->getNode()->getLine()); + $this->assertEquals(7, $lints[1]->getNode()->getLine()); + } + + public function testCatchesInvalidRoutesEvenIfMethodWithIllegalValueIsNotFirst() + { + $file = <<get('newPassword', [NewPasswordController::class, 'create']); +Route::name('newPassword.create')->get('new-password', [NewPasswordController::class, 'create']); +Route::name('newPassword.store') + ->middleware('web') + ->post('newPassword', [NewPasswordController::class, 'store']); +Route::name('newPassword.store') + ->middleware('web') + ->post('new-password', [NewPasswordController::class, 'store']); +TEST; + + $lints = (new TLint)->lint(new RouteUrlsUsesKebabCasing($file)); + + $this->assertSame(2, count($lints)); + $this->assertEquals(3, $lints[0]->getNode()->getLine()); + $this->assertEquals(5, $lints[1]->getNode()->getLine()); + } + + public function testRouteGroupWithValidPrefixPasses() + { + $file = << 'new-password'], function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +}); +TEST; + + $lints = (new TLint)->lint(new RouteUrlsUsesKebabCasing($file)); + + $this->assertSame(0, count($lints)); + } + + public function testRouteGroupWithInvalidPrefixInArrayIsFlagged() + { + $file = << 'newPassword'], function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +})->middleware('testing'); + +Route::group(['prefix' => 'new-password'], function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +})->middleware('testing'); +TEST; + + $lints = (new TLint)->lint(new RouteUrlsUsesKebabCasing($file)); + + $this->assertSame(1, count($lints)); + $this->assertEquals(3, $lints[0]->getNode()->getLine()); + } + + public function testRouteGroupWithInvalidPrefixInArrayAndMultipleKeysIsFlagged() + { + $file = << 'web', 'prefix' => 'newPassword'], function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +})->middleware('testing'); + +Route::group(['middleware' => 'web', 'prefix' => 'new-password'], function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +})->middleware('testing'); +TEST; + + $lints = (new TLint)->lint(new RouteUrlsUsesKebabCasing($file)); + + $this->assertSame(1, count($lints)); + $this->assertEquals(3, $lints[0]->getNode()->getLine()); + } + + public function testRouteGroupWithInvalidPrefixAsMethodIsFlagged() + { + $file = <<group(function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +}); + +Route::prefix('new-password')->group(function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +}); + +Route::group(function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +})->prefix('newPassword'); + +Route::group(function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +})->prefix('new-password'); + +Route::group(function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +})->middleware('web')->prefix('newPassword'); + +Route::group(function () { + Route::get('/', [NewPasswordController::class, 'create'])->name('newPassword.create'); + Route::post('/', [NewPasswordController::class, 'store'])->name('newPassword.store'); +})->middleware('web')->prefix('new-password'); +TEST; + + $lints = (new TLint)->lint(new RouteUrlsUsesKebabCasing($file)); + + $this->assertSame(3, count($lints)); + $this->assertEquals(3, $lints[0]->getNode()->getLine()); + $this->assertEquals(13, $lints[1]->getNode()->getLine()); + $this->assertEquals(23, $lints[2]->getNode()->getLine()); + } + + public function testValidRoutesPassesTheTest() + { + $file = <<name('newPassword.create'); + +Route::get('article-category', [ArticleCategoryController::class, 'index'])->name('articleCategory.index'); +TEST; + + $lints = (new TLint)->lint(new RouteUrlsUsesKebabCasing($file)); + + $this->assertEmpty($lints); + } +}