Skip to content

Commit

Permalink
#9 - Adds linter that enforces kebab-casing for urls and url prefixes.
Browse files Browse the repository at this point in the history
  • Loading branch information
ventrec committed Oct 8, 2023
1 parent 3dc2dc8 commit 439bca7
Show file tree
Hide file tree
Showing 3 changed files with 309 additions and 0 deletions.
137 changes: 137 additions & 0 deletions src/Linters/RouteUrlsUsesKebabCasing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

namespace Indent\LaravelLinter\Linters;

use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\FindingVisitor;
use PhpParser\Parser;
use Tighten\TLint\BaseLinter;
use Tighten\TLint\Linters\Concerns\LintsRoutesFiles;

class RouteUrlsUsesKebabCasing extends BaseLinter
{
use LintsRoutesFiles;

public const DESCRIPTION = 'Route urls must use kebab casing.';

private const ROUTE_METHOD_NAMES = [
'get',
'post',
'patch',
'put',
'delete',
'options',
];

public function lint(Parser $parser)
{
$traverser = new NodeTraverser;

$visitor = new FindingVisitor(function (Node $node) {
if ($this->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;
}
}
2 changes: 2 additions & 0 deletions src/Presets/IndentPreset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +43,7 @@ public function getLinters(): array
NoStringInterpolationWithoutBraces::class,
QualifiedNamesOnlyForClassName::class,
RemoveLeadingSlashNamespaces::class,
RouteUrlsUsesKebabCasing::class,
ControllerHasCorrectOrderForRestMethods::class,
SpaceAfterBladeDirectives::class,
SpacesAroundBladeRenderContent::class,
Expand Down
170 changes: 170 additions & 0 deletions tests/Linters/RouteUrlsUsesKebabCasingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

namespace Tests\Linters;

use Indent\LaravelLinter\Linters\RouteUrlsUsesKebabCasing;
use PHPUnit\Framework\TestCase;
use Tighten\TLint\TLint;

class RouteUrlsUsesKebabCasingTest extends TestCase
{
public function testCatchesInvalidRouteUrls()
{
$file = <<<TEST
<?php
Route::get('newPassword', [NewPasswordController::class, 'create'])->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 = <<<TEST
<?php
Route::name('newPassword.create')->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 = <<<TEST
<?php
Route::group(['prefix' => '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 = <<<TEST
<?php
Route::group(['prefix' => '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 = <<<TEST
<?php
Route::group(['middleware' => '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 = <<<TEST
<?php
Route::prefix('newPassword')->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 = <<<TEST
<?php
Route::get('new-password', [NewPasswordController::class, 'create'])->name('newPassword.create');
Route::get('article-category', [ArticleCategoryController::class, 'index'])->name('articleCategory.index');
TEST;

$lints = (new TLint)->lint(new RouteUrlsUsesKebabCasing($file));

$this->assertEmpty($lints);
}
}

0 comments on commit 439bca7

Please sign in to comment.