Skip to content

Commit 0fa6440

Browse files
authored
[symfony] Add NoRouteTrailingSlashPathRule (#176)
1 parent d599620 commit 0fa6440

File tree

10 files changed

+233
-0
lines changed

10 files changed

+233
-0
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,17 @@ class SomeController
885885

886886
<br>
887887

888+
### NoRouteTrailingSlashPathRule
889+
890+
Avoid trailing slash in route path, to prevent redirects and SEO issues
891+
892+
```yaml
893+
rules:
894+
- Symplify\PHPStanRules\Rules\Symfony\NoRouteTrailingSlashPathRule
895+
```
896+
897+
<br>
898+
888899
### RequireAttributeNamespaceRule
889900

890901
Attribute must be located in "Attribute" namespace

config/symfony-rules.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ rules:
1010
# routing
1111
- Symplify\PHPStanRules\Rules\Symfony\NoRoutingPrefixRule
1212
- Symplify\PHPStanRules\Rules\Symfony\NoClassLevelRouteRule
13+
- Symplify\PHPStanRules\Rules\Symfony\NoRouteTrailingSlashPathRule
1314

1415
# dependency injection
1516
- Symplify\PHPStanRules\Rules\Symfony\NoGetDoctrineInControllerRule

src/Enum/SymfonyRuleIdentifier.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,6 @@ final class SymfonyRuleIdentifier
3333
public const NO_ROUTING_PREFIX = 'symfony.noRoutingPrefix';
3434

3535
public const NO_CLASS_LEVEL_ROUTE = 'symfony.noClassLevelRoute';
36+
37+
public const NO_ROUTE_TRAILING_SLASH_PATH = 'symfony.noRouteTrailingSlashPath';
3638
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace Symplify\PHPStanRules\Rules\Symfony;
4+
5+
use PhpParser\Comment\Doc;
6+
use PhpParser\Node;
7+
use PhpParser\Node\Stmt\ClassMethod;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleError;
11+
use PHPStan\Rules\RuleErrorBuilder;
12+
use Symplify\PHPStanRules\Enum\SymfonyRuleIdentifier;
13+
use Symplify\PHPStanRules\Symfony\NodeAnalyzer\SymfonyControllerAnalyzer;
14+
15+
/**
16+
* @see \Symplify\PHPStanRules\Tests\Rules\Symfony\NoRouteTrailingSlashPathRule\NoRouteTrailingSlashPathRuleTest
17+
*
18+
* @implements Rule<ClassMethod>
19+
*/
20+
final class NoRouteTrailingSlashPathRule implements Rule
21+
{
22+
/**
23+
* @var string
24+
*/
25+
public const ERROR_MESSAGE = 'Avoid trailing slash in route path "%s", to prevent redirects and SEO issues';
26+
27+
public function getNodeType(): string
28+
{
29+
return ClassMethod::class;
30+
}
31+
32+
/**
33+
* @param ClassMethod $node
34+
* @return RuleError[]
35+
*/
36+
public function processNode(Node $node, Scope $scope): array
37+
{
38+
if ($node->isMagic() || ! $node->isPublic()) {
39+
return [];
40+
}
41+
42+
if (! SymfonyControllerAnalyzer::isControllerScope($scope)) {
43+
return [];
44+
}
45+
46+
$routePath = $this->matchRouteDocblockPath($node);
47+
if (! is_string($routePath)) {
48+
return [];
49+
}
50+
51+
// path is valid
52+
if ($routePath === '/' || ! str_ends_with($routePath, '/')) {
53+
return [];
54+
}
55+
56+
$identifierRuleError = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $routePath))
57+
->identifier(SymfonyRuleIdentifier::NO_ROUTE_TRAILING_SLASH_PATH)
58+
->build();
59+
60+
return [$identifierRuleError];
61+
}
62+
63+
private function matchRouteDocblockPath(ClassMethod $classMethod): ?string
64+
{
65+
$docComment = $classMethod->getDocComment();
66+
if (! $docComment instanceof Doc) {
67+
return null;
68+
}
69+
70+
// not a route
71+
if (! str_contains($docComment->getText(), 'Route')) {
72+
return null;
73+
}
74+
75+
/** @see https://regex101.com/r/Qo7aLu/1 */
76+
preg_match('#@Route\((path=)?"(?<path>[\/\w\-]+)"#', $docComment->getText(), $matches);
77+
78+
return $matches['path'] ?? null;
79+
}
80+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoRouteTrailingSlashPathRule\Fixture;
6+
7+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8+
use Symfony\Component\Routing\Annotation\Route;
9+
10+
final class CorrectController extends AbstractController
11+
{
12+
/**
13+
* @Route("/some-route")
14+
*/
15+
public function someAction()
16+
{
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoRouteTrailingSlashPathRule\Fixture;
6+
7+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8+
use Symfony\Component\Routing\Annotation\Route;
9+
10+
final class InvalidController extends AbstractController
11+
{
12+
/**
13+
* @Route("/some-route/")
14+
*/
15+
public function someAction()
16+
{
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoRouteTrailingSlashPathRule\Fixture;
6+
7+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8+
use Symfony\Component\Routing\Annotation\Route;
9+
10+
final class InvalidWithNameController extends AbstractController
11+
{
12+
/**
13+
* @Route("/next-route/", name="more-text")
14+
*/
15+
public function someAction()
16+
{
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoRouteTrailingSlashPathRule\Fixture;
6+
7+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8+
use Symfony\Component\Routing\Annotation\Route;
9+
10+
final class PathAwareInvalidController extends AbstractController
11+
{
12+
/**
13+
* @Route(path="/another-route/")
14+
*/
15+
public function someAction()
16+
{
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoRouteTrailingSlashPathRule\Fixture;
6+
7+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8+
use Symfony\Component\Routing\Annotation\Route;
9+
10+
final class SoleSlashController extends AbstractController
11+
{
12+
/**
13+
* @Route("/")
14+
*/
15+
public function someAction()
16+
{
17+
}
18+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoRouteTrailingSlashPathRule;
6+
7+
use Iterator;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Testing\RuleTestCase;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use Symplify\PHPStanRules\Rules\Symfony\NoRouteTrailingSlashPathRule;
12+
13+
final class NoRouteTrailingSlashPathRuleTest extends RuleTestCase
14+
{
15+
/**
16+
* @param mixed[] $expectedErrorsWithLines
17+
*/
18+
#[DataProvider('provideData')]
19+
public function testRule(string $filePath, array $expectedErrorsWithLines): void
20+
{
21+
$this->analyse([$filePath], $expectedErrorsWithLines);
22+
}
23+
24+
public static function provideData(): Iterator
25+
{
26+
yield [__DIR__ . '/Fixture/SoleSlashController.php', []];
27+
yield [__DIR__ . '/Fixture/CorrectController.php', []];
28+
29+
yield [
30+
__DIR__ . '/Fixture/InvalidController.php',
31+
[[sprintf(NoRouteTrailingSlashPathRule::ERROR_MESSAGE, '/some-route/'), 15]],
32+
];
33+
34+
yield [
35+
__DIR__ . '/Fixture/InvalidWithNameController.php',
36+
[[sprintf(NoRouteTrailingSlashPathRule::ERROR_MESSAGE, '/next-route/'), 15]],
37+
];
38+
39+
yield [
40+
__DIR__ . '/Fixture/PathAwareInvalidController.php',
41+
[[sprintf(NoRouteTrailingSlashPathRule::ERROR_MESSAGE, '/another-route/'), 15]],
42+
];
43+
}
44+
45+
protected function getRule(): Rule
46+
{
47+
return new NoRouteTrailingSlashPathRule();
48+
}
49+
}

0 commit comments

Comments
 (0)