-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
#9 - Adds linter that enforces kebab-casing for urls and url prefixes.
- Loading branch information
Showing
3 changed files
with
309 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |