Skip to content

Commit 39d2919

Browse files
committed
Add Psr4Sniff
to check PSR-4 compliance of files that contain classes auto-loadable entities.
1 parent 1f00459 commit 39d2919

File tree

6 files changed

+315
-1
lines changed

6 files changed

+315
-1
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## Not released
4+
- Add `Psr4Sniff` to check PSR-4 compliance of files that contain classes auto-loadable entities.
5+
36
## 0.9.0
47
- `ReturnTypeDeclarationSniff` do no warn for missing return type when a docbloc like:
58
`@return {aType}|null` exists for the function.

Inpsyde/Helpers.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,4 +559,43 @@ public static function functionDocBlockTag(
559559

560560
return $tags;
561561
}
562+
563+
public static function findNamespace(File $file, int $position): array
564+
{
565+
$tokens = $file->getTokens();
566+
$namespacePos = $file->findPrevious([T_NAMESPACE], $position - 1);
567+
if (!$namespacePos || !array_key_exists($namespacePos, $tokens)) {
568+
return [null, null];
569+
}
570+
571+
$end = $file->findNext(
572+
[T_SEMICOLON, T_OPEN_CURLY_BRACKET],
573+
$namespacePos + 1,
574+
null,
575+
false,
576+
null,
577+
true
578+
);
579+
580+
if (!$end || !array_key_exists($end, $tokens)) {
581+
return [null, null];
582+
}
583+
584+
if ($tokens[$end]['code'] === T_OPEN_CURLY_BRACKET
585+
&& ! empty($tokens[$end]['scope_closer'])
586+
&& $tokens[$end]['scope_closer'] < $position
587+
) {
588+
return [null, null];
589+
}
590+
591+
$namespace = '';
592+
for ($i = $namespacePos + 1; $i < $end; $i++) {
593+
$code = $tokens[$i]['code'] ?? null;
594+
if (in_array($code, [T_STRING, T_NS_SEPARATOR], true)) {
595+
$namespace .= $tokens[$i]['content'] ?? '';
596+
}
597+
}
598+
599+
return [$namespacePos, $namespace];
600+
}
562601
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php declare(strict_types=1); # -*- coding: utf-8 -*-
2+
/*
3+
* This file is part of the php-coding-standards package.
4+
*
5+
* (c) Inpsyde GmbH
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
11+
namespace Inpsyde\InpsydeCodingStandard\Sniffs\CodeQuality;
12+
13+
use Inpsyde\InpsydeCodingStandard\Helpers;
14+
use PHP_CodeSniffer\Files\File;
15+
use PHP_CodeSniffer\Sniffs\Sniff;
16+
17+
/**
18+
* @package php-coding-standards
19+
* @license http://opensource.org/licenses/MIT MIT
20+
*/
21+
final class Psr4Sniff implements Sniff
22+
{
23+
/**
24+
* @var string|null
25+
*/
26+
public $psr4 = null;
27+
28+
/**
29+
* @var array
30+
*/
31+
public $exclude = [];
32+
33+
/**
34+
* @return int[]
35+
*/
36+
public function register()
37+
{
38+
return [T_CLASS, T_INTERFACE, T_TRAIT];
39+
}
40+
41+
/**
42+
* @param File $file
43+
* @param int $position
44+
* @throws \PHP_CodeSniffer\Exceptions\RuntimeException
45+
*/
46+
public function process(File $file, $position)
47+
{
48+
$className = $file->getDeclarationName($position);
49+
$code = $file->getTokens()[$position]['code'];
50+
$entityType = 'class';
51+
if ($code !== T_CLASS) {
52+
$entityType = $code === T_TRAIT ? 'trait' : 'interface';
53+
}
54+
55+
$this->exclude = $this->normalizeExcluded($this->exclude);
56+
57+
$validNamespace = is_array($this->psr4) && $this->psr4
58+
? $this->checkNamespace($file, $position, $entityType, $className)
59+
: true;
60+
61+
if (!$validNamespace) {
62+
return;
63+
}
64+
65+
$fileName = basename($file->getFilename());
66+
if ($fileName === "{$className}.php") {
67+
return;
68+
}
69+
70+
$file->addError(
71+
sprintf(
72+
"File containing %s '%s' is named '%s' instead of '%s'.",
73+
$entityType,
74+
$className,
75+
$fileName,
76+
"{$className}.php"
77+
),
78+
$position,
79+
'WrongFilename'
80+
);
81+
}
82+
83+
/**
84+
* @param File $file
85+
* @param int $position
86+
* @param string $entityType
87+
* @param string $className
88+
* @return bool
89+
*/
90+
private function checkNamespace(
91+
File $file,
92+
int $position,
93+
string $entityType,
94+
string $className
95+
) {
96+
97+
list($namespacePos, $namespace) = Helpers::findNamespace($file, $position);
98+
99+
$fullyQualifiedName = "{$namespace}\\{$className}";
100+
if (in_array($fullyQualifiedName, $this->exclude, true)) {
101+
return true;
102+
}
103+
104+
list($baseNamespace, $baseFolder) = $this->classPsr4Info($namespace);
105+
106+
if (!$baseNamespace || !$namespacePos) {
107+
$file->addError(
108+
sprintf(
109+
"Namespace '%s' is not compliant with given PSR-4 configuration.",
110+
$namespace
111+
),
112+
$namespacePos,
113+
'NotInPSR4'
114+
);
115+
116+
return false;
117+
}
118+
119+
$namespaceRemain = trim(substr($namespace, strlen($baseNamespace)), '\\');
120+
$expectedDirChunks = explode('\\', $namespaceRemain);
121+
array_unshift($expectedDirChunks, $baseFolder);
122+
123+
$classPath = dirname($file->getFilename());
124+
$classDirChunks = explode('/', str_replace('\\', '/', $classPath));
125+
$actualDirChunks = array_slice($classDirChunks, -1 * count($expectedDirChunks));
126+
127+
if ($expectedDirChunks === $actualDirChunks) {
128+
return true;
129+
}
130+
131+
$file->addError(
132+
sprintf(
133+
"%s '%s', located in folder '%s', is not compliant with PSR-4 configuration.",
134+
ucfirst($entityType),
135+
$fullyQualifiedName,
136+
$classPath
137+
),
138+
$namespacePos,
139+
'InvalidPSR4'
140+
);
141+
142+
return false;
143+
}
144+
145+
/**
146+
* @param array $excluded
147+
* @return array
148+
*/
149+
private function normalizeExcluded(array $excluded): array
150+
{
151+
return array_map(
152+
function (string $className): string {
153+
return ltrim($className, '\\');
154+
},
155+
$excluded
156+
);
157+
}
158+
159+
/**
160+
* @param string $namespace
161+
* @return array
162+
*/
163+
private function classPsr4Info(string $namespace): array
164+
{
165+
$classBaseNamespace = null;
166+
$classBaseFolder = null;
167+
foreach ($this->psr4 as $baseNamespace => $folder) {
168+
$baseNamespace = trim($baseNamespace, '\\');
169+
if (strpos($namespace, $baseNamespace) === 0) {
170+
$classBaseNamespace = $baseNamespace;
171+
$classBaseFolder = trim(str_replace('\\', '/', $folder), './');
172+
break;
173+
}
174+
}
175+
176+
return [$classBaseNamespace, $classBaseFolder];
177+
}
178+
}

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,11 @@ Customs rules are:
143143
- Discourage usage of `define` where `const` is preferable.
144144
- Discourage usage of more than 10 properties per class.
145145
- Enforce return type declaration, with few exceptions (e.g. hook callbacks or `ArrayAccess` methods)
146+
- Check PSR-4 compliance
146147

147148
The tree of used rules are listed in the `/docs/rules-list/custom.md` file in this repo.
148149

149-
### Notes
150+
### Notes & Configuration
150151

151152
#### Skip `InpsydeCodingStandard.CodeQuality.ReturnTypeDeclaration.NoReturnType` via doc bloc
152153

@@ -158,6 +159,44 @@ However, if min PHP version is set to 7.1 via php-compatibility `testVersion` co
158159
Also note that the warning **is** shown in case:
159160
- the `@return` docbloc declares more than one not-null types, e.g. `@return Foo|Bar|null`
160161
- the `@return` docbloc types contains "mixed", e.g. `@return mixed|null`.
162+
163+
164+
#### PSR-4 Configuration
165+
`InpsydeCodingStandard.CodeQuality.Psr4` rule needs some configuration to check namespace and
166+
class file paths.
167+
Without configuration the only thing the sniff does is to check that class name and file name match.
168+
The needed configuration mimics the PSR-4 configuration in `composer.json`.
169+
Assuming a `composer.json` like:
170+
```json
171+
{
172+
"autoload": {
173+
"psr-4": {
174+
"Inpsyde\\Foo\\": "src/",
175+
"Inpsyde\\Foo\\Bar\\Baz\\": "baz/"
176+
}
177+
}
178+
}
179+
```
180+
the rule configuration should be:
181+
```xml
182+
<rule ref="InpsydeCodingStandard.CodeQuality.Psr4">
183+
<properties>
184+
<property name="psr4" type="array" value="Inpsyde\Foo=>src,Inpsyde\Foo\Bar\Baz=>baz" />
185+
</properties>
186+
</rule>
187+
```
188+
Please note that when a PSR-4 configuration is given, *all* autoloadable entities (classes/interfaces/trait)
189+
are checked to be compliant.
190+
If there are entities in the sniffer target paths that are not PSR-4 compliant (e.g. loaded via classmap
191+
or not autoloaded at all) those should be excluded via `exclude` property, e.g.
192+
```xml
193+
<rule ref="InpsydeCodingStandard.CodeQuality.Psr4">
194+
<properties>
195+
<property name="psr4" type="array" value="Inpsyde\SomePlugin=>src" />
196+
<property name="exclude" type="array" value="Inpsyde\ExcludeThis,Inpsyde\AndThis" />
197+
</properties>
198+
</rule>
199+
```
161200

162201
-------------
163202

docs/rules-list/custom.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
- InpsydeCodingStandard.CodeQuality.NoAccessors.NoSetter
2121
- InpsydeCodingStandard.CodeQuality.PropertyPerClassLimit
2222
- InpsydeCodingStandard.CodeQuality.PropertyPerClassLimit.TooMuchProperties
23+
- InpsydeCodingStandard.CodeQuality.Psr4
24+
- InpsydeCodingStandard.CodeQuality.Psr4.WrongFilename
25+
- InpsydeCodingStandard.CodeQuality.Psr4.NotInPSR4
26+
- InpsydeCodingStandard.CodeQuality.Psr4.InvalidPSR4
2327
- InpsydeCodingStandard.CodeQuality.ReturnTypeDeclaration
2428
- InpsydeCodingStandard.CodeQuality.ReturnTypeDeclaration.MissingReturn
2529
- InpsydeCodingStandard.CodeQuality.ReturnTypeDeclaration.IncorrectVoidReturn

tests/fixtures/Psr4Fixture.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php # -*- coding: utf-8 -*-
2+
// @phpcsSniff CodeQuality.Psr4
3+
4+
namespace {
5+
6+
// @phpcsSniffPropertiesStart
7+
$psr4 = ["\\Inpsyde\\InpsydeCodingStandard\\Tests\\" => "tests/"];
8+
$exclude = ["\\I\\Am\\Excluded\\Psr4Fixture"];
9+
// @phpcsSniffPropertiesEnd
10+
}
11+
12+
13+
namespace Inpsyde\InpsydeCodingStandard\Tests\fixtures {
14+
15+
class Psr4Fixture
16+
{
17+
18+
}
19+
20+
// @phpcsErrorCodeOnNextLine WrongFilename
21+
class ThisIsWrong
22+
{
23+
24+
}
25+
}
26+
27+
// @phpcsErrorCodeOnNextLine NotInPSR4
28+
namespace Inpsyde\InpsydeCodingStandard\Foo\Bar {
29+
30+
interface ThisIsWrong
31+
{
32+
33+
}
34+
}
35+
36+
// @phpcsErrorCodeOnNextLine InvalidPSR4
37+
namespace Inpsyde\InpsydeCodingStandard\Tests\Bar {
38+
39+
trait ThisIsWrong
40+
{
41+
42+
}
43+
}
44+
45+
namespace I\Am\Excluded {
46+
47+
interface Psr4Fixture
48+
{
49+
50+
}
51+
}

0 commit comments

Comments
 (0)