├── composer.json └── src └── Suin ├── Sniffs └── Classes │ ├── PSR4 │ ├── AutoloadabilityInspector.php │ ├── AutoloadabilityInspectors.php │ ├── AutoloadabilityInspectorsFactory.php │ ├── AutoloadableClass.php │ ├── ClassFileUnderInspection.php │ ├── InspectionResult.php │ ├── NonAutoloadableClass.php │ └── PSR4UnrelatedClass.php │ └── PSR4Sniff.php └── ruleset.xml /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "suin/phpcs-psr4-sniff", 3 | "type": "phpcodesniffer-standard", 4 | "description": "PHP_CodeSniffer sniff that checks class name matches PSR-4 project structure.", 5 | "keywords": [ 6 | "coding-standards", 7 | "coding-style", 8 | "namespace", 9 | "php-codesniffer", 10 | "phpcs", 11 | "psr-4", 12 | "static-analysis" 13 | ], 14 | "homepage": "https://github.com/suin/php", 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "suin", 19 | "email": "suinyeze@gmail.com", 20 | "homepage": "https://github.com/suin", 21 | "role": "Developer" 22 | } 23 | ], 24 | "minimum-stability": "stable", 25 | "prefer-stable": true, 26 | "support": { 27 | "issues": "https://github.com/suin/php/issues" 28 | }, 29 | "require": { 30 | "php": ">=7.1", 31 | "ext-json": "*", 32 | "slevomat/coding-standard": ">=4.7 <8.0.0", 33 | "squizlabs/php_codesniffer": ">=3.3 <4.0.0" 34 | }, 35 | "autoload": { 36 | "psr-0": { 37 | "Suin\\Sniffs\\Classes\\": "src" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Suin\\Sniffs\\Classes\\": "tests" 43 | } 44 | }, 45 | "config": { 46 | "sort-packages": true 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Suin/Sniffs/Classes/PSR4/AutoloadabilityInspector.php: -------------------------------------------------------------------------------- 1 | baseDirectory = \rtrim($baseDirectory, '/') . '/'; 22 | $this->namespacePrefix = \rtrim($namespacePrefix, '\\') . '\\'; 23 | } 24 | 25 | public function inspect( 26 | ClassFileUnderInspection $classFile 27 | ): InspectionResult { 28 | return $this->classFileIsUnderBaseDirectory($classFile) ? 29 | $this->inspectAutoloadability($classFile) : 30 | new PSR4UnrelatedClass(); 31 | } 32 | 33 | private function classFileIsUnderBaseDirectory( 34 | ClassFileUnderInspection $classFile 35 | ): bool { 36 | return \strpos($classFile->getFileName(), $this->baseDirectory) === 0; 37 | } 38 | 39 | private function inspectAutoloadability( 40 | ClassFileUnderInspection $classFile 41 | ): InspectionResult { 42 | $expectedClassName = $this->guessExpectedClassName($classFile); 43 | $actualClassName = $classFile->getClassName(); 44 | return $expectedClassName === $actualClassName ? 45 | new AutoloadableClass() : 46 | new NonAutoloadableClass($expectedClassName, $actualClassName); 47 | } 48 | 49 | private function guessExpectedClassName( 50 | ClassFileUnderInspection $classFile 51 | ): string { 52 | $relativeFileName = $this->guessRelativeFileName($classFile); 53 | $relativeClassName = $this->guessRelativeClassName($relativeFileName); 54 | return $this->guessFullyQualifiedClassName($relativeClassName); 55 | } 56 | 57 | private function guessRelativeFileName( 58 | ClassFileUnderInspection $classFile 59 | ): string { 60 | \assert($this->directoryEndsWithSlash()); 61 | \assert($this->classFileIsUnderBaseDirectory($classFile)); 62 | return \substr( 63 | $classFile->getFileName(), 64 | \strlen($this->baseDirectory) 65 | ); 66 | } 67 | 68 | private function guessRelativeClassName(string $relativeFileName): string 69 | { 70 | $basename = \basename($relativeFileName); 71 | $filename = \pathinfo($relativeFileName, \PATHINFO_FILENAME); 72 | $dirname = $basename === $relativeFileName ? 73 | '' : 74 | \pathinfo($relativeFileName, \PATHINFO_DIRNAME) . '/'; 75 | return \str_replace('/', '\\', $dirname) . $filename; 76 | } 77 | 78 | private function guessFullyQualifiedClassName( 79 | string $relativeClassName 80 | ): string { 81 | \assert($this->namespaceEndsWithBackslash()); 82 | return $this->namespacePrefix . $relativeClassName; 83 | } 84 | 85 | private function directoryEndsWithSlash(): bool 86 | { 87 | return \substr($this->baseDirectory, -1) === '/'; 88 | } 89 | 90 | private function namespaceEndsWithBackslash(): bool 91 | { 92 | return \substr($this->namespacePrefix, -1) === '\\'; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Suin/Sniffs/Classes/PSR4/AutoloadabilityInspectors.php: -------------------------------------------------------------------------------- 1 | inspectors = $inspectors; 17 | } 18 | 19 | /** 20 | * @noinspection MultipleReturnStatementsInspection 21 | */ 22 | public function inspect( 23 | ClassFileUnderInspection $classFile 24 | ): InspectionResult { 25 | foreach ($this->inspectors as $inspector) { 26 | $result = $inspector->inspect($classFile); 27 | 28 | if ($result->isPsr4RelatedClass()) { 29 | return $result; 30 | } 31 | } 32 | return new PSR4UnrelatedClass(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Suin/Sniffs/Classes/PSR4/AutoloadabilityInspectorsFactory.php: -------------------------------------------------------------------------------- 1 | $dirs) { 44 | if (!is_array($dirs)) { 45 | $dirs = [$dirs]; 46 | } 47 | foreach ($dirs as $dir) { 48 | $psr4Directories[] = new AutoloadabilityInspector( 49 | \dirname($filename) . '/' . $dir, 50 | $namespace 51 | ); 52 | } 53 | } 54 | } 55 | 56 | if (isset($data['autoload-dev']['psr-4'])) { 57 | foreach ($data['autoload-dev']['psr-4'] as $namespace => $dirs) { 58 | if (!is_array($dirs)) { 59 | $dirs = [$dirs]; 60 | } 61 | foreach ($dirs as $dir) { 62 | $psr4Directories[] = new AutoloadabilityInspector( 63 | \dirname($filename) . '/' . $dir, 64 | $namespace 65 | ); 66 | } 67 | } 68 | } 69 | return new AutoloadabilityInspectors(...$psr4Directories); 70 | } 71 | 72 | private static function resolveComposerJsonPath( 73 | ?string $basePath, 74 | string $composerJsonPath 75 | ): string { 76 | return $basePath === null ? 77 | $composerJsonPath : 78 | $basePath . '/' . $composerJsonPath; 79 | } 80 | 81 | private static function assertFileExists(string $filename): void 82 | { 83 | if (!\is_file($filename)) { 84 | throw new InvalidArgumentException( 85 | "composer.json file not found: ${filename}" 86 | ); 87 | } 88 | } 89 | 90 | private static function assertFileIsReadable(string $filename): void 91 | { 92 | if (!\is_readable($filename)) { 93 | throw new InvalidArgumentException( 94 | "composer.json file is not readable: ${filename}" 95 | ); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Suin/Sniffs/Classes/PSR4/AutoloadableClass.php: -------------------------------------------------------------------------------- 1 | fileName = $fileName; 22 | $this->className = \ltrim($className, '\\'); 23 | } 24 | 25 | public function getFileName(): string 26 | { 27 | return $this->fileName; 28 | } 29 | 30 | public function getClassName(): string 31 | { 32 | return $this->className; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Suin/Sniffs/Classes/PSR4/InspectionResult.php: -------------------------------------------------------------------------------- 1 | expectedClassName = $expectedClassName; 24 | $this->actualClassName = $actualClassName; 25 | } 26 | 27 | public function isAutoloadable(): bool 28 | { 29 | return false; 30 | } 31 | 32 | public function isPsr4RelatedClass(): bool 33 | { 34 | return true; 35 | } 36 | 37 | public function getExpectedClassName(): string 38 | { 39 | return $this->expectedClassName; 40 | } 41 | 42 | public function getActualClassName(): string 43 | { 44 | return $this->actualClassName; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Suin/Sniffs/Classes/PSR4/PSR4UnrelatedClass.php: -------------------------------------------------------------------------------- 1 | initializeThisSniffIfNotYet($phpcsFile->config); 60 | 61 | if ($this->initialization === self::INITIALIZATION_FAILURE) { 62 | return; 63 | } 64 | 65 | $classFile = $this->getClassFileOf($phpcsFile, $typePointer); 66 | $result = $this->autoloadabilityInspectors->inspect($classFile); 67 | 68 | if ($result instanceof NonAutoloadableClass) { 69 | $this->addError($phpcsFile, $result, $typePointer); 70 | } 71 | } 72 | 73 | private function initializeThisSniffIfNotYet(Config $config): void 74 | { 75 | if ($this->initialization === self::UNINITIALIZED) { 76 | $this->initialization = self::INITIALIZATION_FAILURE; 77 | $this->autoloadabilityInspectors = 78 | AutoloadabilityInspectorsFactory::create( 79 | $config->getSettings()['basepath'], 80 | $this->composerJsonPath 81 | ); 82 | $this->initialization = self::INITIALIZED; 83 | } 84 | } 85 | 86 | private function getClassFileOf( 87 | File $phpcsFile, 88 | $typePointer 89 | ): ClassFileUnderInspection { 90 | return new ClassFileUnderInspection( 91 | $phpcsFile->getFilename(), 92 | ClassHelper::getFullyQualifiedName($phpcsFile, $typePointer) 93 | ); 94 | } 95 | 96 | private function addError( 97 | File $phpcsFile, 98 | NonAutoloadableClass $result, 99 | int $typePointer 100 | ): void { 101 | $phpcsFile->addError( 102 | \sprintf( 103 | 'Class name is not compliant with PSR-4 configuration. ' . 104 | 'It should be `%s` instead of `%s`.', 105 | $result->getExpectedClassName(), 106 | $result->getActualClassName() 107 | ), 108 | $this->getClassNameDeclarationPosition($phpcsFile, $typePointer), 109 | self::CODE_INCORRECT_CLASS_NAME 110 | ); 111 | } 112 | 113 | private function getClassNameDeclarationPosition( 114 | File $phpcsFile, 115 | int $typePointer 116 | ): ?int { 117 | return TokenHelper::findNext($phpcsFile, \T_STRING, $typePointer + 1); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Suin/ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | --------------------------------------------------------------------------------