├── src └── WorksomeSniff │ ├── Sniffs │ ├── Comments │ │ └── DisallowTodoCommentsSniff.php │ ├── Laravel │ │ ├── DisallowEnvUsageSniff.php │ │ ├── ConfigFilenameKebabCaseSniff.php │ │ ├── EventListenerSuffixSniff.php │ │ ├── DisallowBladeOutsideOfResourcesDirectorySniff.php │ │ └── DisallowHasFactorySniff.php │ ├── PhpDoc │ │ ├── DisallowParamNoTypeOrCommentSniff.php │ │ └── PropertyDollarSignSniff.php │ ├── Classes │ │ └── ExceptionSuffixSniff.php │ └── Functions │ │ └── DisallowCompactUsageSniff.php │ ├── Support │ └── PropertyDoc.php │ └── ruleset.xml ├── .github └── workflows │ └── main.yml ├── LICENSE ├── composer.json └── README.md /src/WorksomeSniff/Sniffs/Comments/DisallowTodoCommentsSniff.php: -------------------------------------------------------------------------------- 1 | getTokensAsString($stackPtr, 1), 'TODO') === false) { 23 | return; 24 | } 25 | 26 | $phpcsFile->addError( 27 | "Comments with TODO are disallowed", 28 | $stackPtr, 29 | self::class 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | setup: 8 | timeout-minutes: 30 9 | runs-on: ubuntu-latest 10 | name: Pest on PHP ${{ matrix.php }} with composer ${{ matrix.dependencies }} 11 | strategy: 12 | matrix: 13 | php: ["8.0", "8.1"] 14 | dependencies: ["lowest", "highest"] 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | coverage: none 24 | php-version: ${{ matrix.php }} 25 | env: 26 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Install Composer dependencies 29 | uses: ramsey/composer-install@v2 30 | with: 31 | dependency-versions: ${{ matrix.dependencies }} 32 | 33 | - name: Test php code 34 | run: vendor/bin/pest 35 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/Laravel/DisallowEnvUsageSniff.php: -------------------------------------------------------------------------------- 1 | getTokensAsString($stackPtr, 1); 20 | $path = $phpcsFile->getFilename(); 21 | 22 | // Check if method is env method. 23 | if (strtolower($string) !== 'env') { 24 | return; 25 | } 26 | 27 | // Allow `env` usage in config files. 28 | if (str_contains($path, 'config/')) { 29 | return; 30 | } 31 | 32 | $phpcsFile->addError( 33 | "Usage of env in non-config file is disallowed.", 34 | $stackPtr, 35 | self::class 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/Laravel/ConfigFilenameKebabCaseSniff.php: -------------------------------------------------------------------------------- 1 | getFilename(); 21 | 22 | // Filter away non config files. 23 | if (!str_contains($path, 'config/')) { 24 | return; 25 | } 26 | 27 | $filenameWithExtension = basename($path); 28 | $filename = Str::before($filenameWithExtension, '.'); 29 | 30 | if (Str::kebab($filename) === $filename) { 31 | return; 32 | } 33 | 34 | $phpcsFile->addError( 35 | "Config files should be named with kebab-case.", 36 | 0, 37 | self::class, 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Worksome 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/Laravel/EventListenerSuffixSniff.php: -------------------------------------------------------------------------------- 1 | getFilename(); 23 | 24 | // Filter away non listener classes 25 | if (!str_contains($path, 'app/Listeners/')) { 26 | return; 27 | } 28 | 29 | $classNamePointer = $stackPtr + 2; 30 | $className = $phpcsFile->getTokensAsString($classNamePointer, 1); 31 | 32 | // Check if class is named with Listener suffix. 33 | if (Str::endsWith($className, $this->suffix)) { 34 | return; 35 | } 36 | 37 | $phpcsFile->addError( 38 | "Listeners should have `Listener` suffix.", 39 | $classNamePointer, 40 | self::class, 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/PhpDoc/DisallowParamNoTypeOrCommentSniff.php: -------------------------------------------------------------------------------- 1 | getTokensAsString($stackPtr, 1), '@param')) { 22 | return; 23 | } 24 | 25 | $value = $phpcsFile->getTokensAsString($stackPtr+2, 1); 26 | 27 | // Check if param tag with no type or comment 28 | $regex = '/^\$\w+\s*$/m'; 29 | 30 | if (!\Safe\preg_match($regex, $value, $matches)) { 31 | return; 32 | } 33 | 34 | $phpcsFile->addFixableError( 35 | "@param tags with no type or comment are disallowed", 36 | $stackPtr, 37 | self::class 38 | ); 39 | 40 | foreach (range(-4, 2) as $pointer) { 41 | $phpcsFile->fixer->replaceToken( 42 | $stackPtr+$pointer, 43 | "" 44 | ); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worksome/code-sniffer", 3 | "description": "Worksome's preferences and custom sniffers for phpcs", 4 | "type": "phpcodesniffer-standard", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Oliver Nybroe", 9 | "email": "oliver@worksome.com" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": { 14 | "Worksome\\WorksomeSniff\\": "src/WorksomeSniff" 15 | } 16 | }, 17 | "autoload-dev": { 18 | "psr-4": { 19 | "Worksome\\WorksomeSniff\\Tests\\": "tests" 20 | } 21 | }, 22 | "require": { 23 | "PHP": "^8.0", 24 | "squizlabs/php_codesniffer": "^3.6.1", 25 | "slevomat/coding-standard": "^8.5", 26 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", 27 | "thecodingmachine/safe": "^2.0", 28 | "illuminate/support": "^8.78 || ^9.0", 29 | "illuminate/collections": "^8.78 || ^9.0", 30 | "illuminate/contracts": "^8.78 || ^9.0" 31 | }, 32 | "require-dev": { 33 | "pestphp/pest": "^1.21", 34 | "symfony/var-dumper": "^5.3" 35 | }, 36 | "minimum-stability": "dev", 37 | "config": { 38 | "allow-plugins": { 39 | "dealerdirect/phpcodesniffer-composer-installer": true, 40 | "pestphp/pest-plugin": true 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/Laravel/DisallowBladeOutsideOfResourcesDirectorySniff.php: -------------------------------------------------------------------------------- 1 | getFilename(), '.blade.php')) { 24 | return; 25 | } 26 | 27 | if (str_contains($phpcsFile->getFilename(), $this->resourcesDirectory())) { 28 | return; 29 | } 30 | 31 | if ($this->errorAlreadyRegisteredForFile) { 32 | return; 33 | } 34 | 35 | $phpcsFile->addError( 36 | "Blade files must be placed in the resources directory.", 37 | $stackPtr, 38 | self::class 39 | ); 40 | 41 | $this->errorAlreadyRegisteredForFile = true; 42 | } 43 | 44 | private function resourcesDirectory(): string 45 | { 46 | return $this->resourcesDirectory ?? getcwd() . DIRECTORY_SEPARATOR . 'resources'; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/Classes/ExceptionSuffixSniff.php: -------------------------------------------------------------------------------- 1 | getTokensAsString($stackPtr + 2, $baseClassNameLength + 1),'\\')) { 25 | $baseClassNameLength += 2; 26 | } 27 | $baseClassName = $phpcsFile->getTokensAsString($stackPtr +2, max($baseClassNameLength, 1)); 28 | 29 | if (!Str::endsWith($baseClassName, $this->suffix) || !Str::contains($baseClassName, 'Exception')) { 30 | return; 31 | } 32 | 33 | $classNamePointer = $stackPtr - 2; 34 | 35 | $className = $phpcsFile->getTokensAsString($classNamePointer, 1); 36 | 37 | // Check if class is named with Exceptions suffix. 38 | if (Str::endsWith($className, $this->suffix)) { 39 | return; 40 | } 41 | 42 | $phpcsFile->addError( 43 | "Exceptions should have `Exception` suffix.", 44 | $classNamePointer, 45 | self::class, 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/Laravel/DisallowHasFactorySniff.php: -------------------------------------------------------------------------------- 1 | getTokensAsString($traitNamePointer, 10), ';'); 26 | 27 | $this->trackRelevantPartialNamespace($className); 28 | 29 | if ($this->isHasFactoryNamespace($className)) { 30 | $this->addError($phpcsFile, $traitNamePointer); 31 | } 32 | } 33 | 34 | private function isHasFactoryNamespace(string $givenNamespace): bool 35 | { 36 | if ($this->partial !== null && strlen($this->partial) > 0) { 37 | $givenNamespace = $this->partial . Str::after($givenNamespace, Str::afterLast($this->partial, "\\")); 38 | } 39 | 40 | if ($givenNamespace !== self::HAS_FACTORY_FQCN) { 41 | return false; 42 | } 43 | 44 | return true; 45 | } 46 | 47 | private function trackRelevantPartialNamespace(string $givenNamespace): void 48 | { 49 | Str::of(self::HAS_FACTORY_FQCN) 50 | ->explode("\\") 51 | ->skip(1) 52 | ->reduce(function (string $carry, string $item) use ($givenNamespace) { 53 | $updatedNamespacePartial = "{$carry}\\{$item}"; 54 | 55 | if (Str::contains($givenNamespace, $updatedNamespacePartial)) { 56 | $this->partial = $updatedNamespacePartial; 57 | } 58 | 59 | return $updatedNamespacePartial; 60 | }, 'Illuminate'); 61 | } 62 | 63 | private function addError(File $phpcsFile, int $line): void 64 | { 65 | $phpcsFile->addError( 66 | "Models should not use the `HasFactory` trait.", 67 | $line, 68 | self::class, 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/PhpDoc/PropertyDollarSignSniff.php: -------------------------------------------------------------------------------- 1 | getLineOfDocblock($phpcsFile, $stackPtr)); 23 | } catch (InvalidArgumentException) { 24 | return; 25 | } 26 | 27 | if ($propertyDoc->variableHasDollarSymbol()) { 28 | return; 29 | } 30 | 31 | $phpcsFile->addFixableError( 32 | "All @property variables should start with a dollar symbol.", 33 | $stackPtr, 34 | self::class 35 | ); 36 | 37 | if ($phpcsFile->fixer === null) { 38 | return; 39 | } 40 | 41 | $this->replaceLineOfDocblock($phpcsFile, $stackPtr, " {$propertyDoc->joined()}" . PHP_EOL); 42 | } 43 | 44 | private function getLineOfDocblock(File $phpcsFile, int $startPtr): string 45 | { 46 | $currentPointer = $startPtr; 47 | $content = ''; 48 | 49 | while (! str_ends_with($content, PHP_EOL)) { 50 | if ($phpcsFile->numTokens === $currentPointer) { 51 | continue; 52 | } 53 | 54 | $content .= $phpcsFile->getTokensAsString($currentPointer, 1); 55 | $currentPointer += 1; 56 | } 57 | 58 | return trim($content); 59 | } 60 | 61 | private function replaceLineOfDocblock(File $phpcsFile, int $startPtr, string $newContent): void 62 | { 63 | $eolPointer = $startPtr; 64 | $content = ''; 65 | 66 | do { 67 | if ($phpcsFile->numTokens === $eolPointer) { 68 | continue; 69 | } 70 | 71 | $eolPointer += 1; 72 | $content = $phpcsFile->getTokensAsString($eolPointer, 1); 73 | } while (! str_ends_with($content, PHP_EOL)); 74 | 75 | foreach (array_reverse(range($startPtr, $eolPointer)) as $ptrToDelete) { 76 | $phpcsFile->fixer->replaceToken($ptrToDelete, ''); 77 | } 78 | 79 | $phpcsFile->fixer->replaceToken($startPtr - 1, $newContent); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Sniffs/Functions/DisallowCompactUsageSniff.php: -------------------------------------------------------------------------------- 1 | getTokensAsString($stackPtr, 1); 20 | 21 | // Check if method is env method. 22 | if (strtolower($methodName) !== 'compact') { 23 | return; 24 | } 25 | 26 | $phpcsFile->addFixableError( 27 | "Usage of compact function is disallowed.", 28 | $stackPtr, 29 | self::class 30 | ); 31 | 32 | [$variables, $lastTokenPointer] = $this->findVariablesInCompactCall($phpcsFile, $stackPtr); 33 | 34 | $phpCode = $this->generatePhpArray($variables); 35 | 36 | // Remove all the old code after the `compact` string. 37 | foreach (range(1, $lastTokenPointer-1) as $currentPointer) { 38 | $phpcsFile->fixer->replaceToken( 39 | $stackPtr+$currentPointer, 40 | "" 41 | ); 42 | } 43 | 44 | // Replace the `compact` string with our new array. 45 | $phpcsFile->fixer->replaceToken( 46 | $stackPtr, 47 | $phpCode 48 | ); 49 | } 50 | 51 | private function generatePhpArray(array $variables): string 52 | { 53 | $phpCode = '['; 54 | foreach ($variables as $variable) { 55 | $phpCode .= "'$variable' => \$$variable, "; 56 | } 57 | 58 | // Remove the trailing `, ` from the array. 59 | $phpCode = substr($phpCode, 0, -2); 60 | // Close the array 61 | $phpCode .= "]"; 62 | 63 | return $phpCode; 64 | } 65 | 66 | private function findVariablesInCompactCall(File $phpcsFile, int $stackPtr): array 67 | { 68 | $variables = []; 69 | $pointer = 1; 70 | 71 | do { 72 | $token = $phpcsFile->getTokensAsString($stackPtr + $pointer, 1); 73 | $pointer++; 74 | 75 | if (!\Safe\preg_match("/['\"](.*?)['\"]/", $token, $matches)) { 76 | continue; 77 | } 78 | 79 | $variableName = $matches[1]; 80 | $variables[] = $variableName; 81 | } while ($token !== ')'); 82 | 83 | return [$variables, $pointer, $matches]; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | :warning: Deprecated in favor of [coding-style](https://github.com/worksome/coding-style) package :warning: 2 | 3 | # Worksome's custom sniffs 4 | This package contains all of Worksome's custom sniffs and configuration. 5 | 6 | ``` 7 | composer require worksome/code-sniffer --dev 8 | ``` 9 | 10 | ## Usage 11 | Create a `phpcs.xml` in the root of your project with content like the following 12 | ```xml 13 | 14 | 15 | YourCompany Coding Standard 16 | 17 | ./app 18 | ./tests 19 | ./config 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ``` 29 | 30 | Run phpcs for seeing if you have any errors 31 | ``` 32 | vendor/bin/phpcs 33 | ``` 34 | Or run phpcbf for automatically fixing the errors when possible 35 | ``` 36 | vendor/bin/phpcbf 37 | ``` 38 | 39 | We suggest adding the commands as scripts in your `composer.json` for easier execution. 40 | ```json 41 | ... 42 | "scripts": { 43 | "phpcs": "vendor/bin/phpcs", 44 | "phpcbf": "vendor/bin/phpcbf" 45 | } 46 | ... 47 | ``` 48 | 49 | This way you can now execute them via composer 50 | ``` 51 | $ composer phpcs 52 | $ composer phpcbf 53 | ``` 54 | 55 | 56 | # Custom sniffs 57 | List all the custom sniffs created by Worksome. 58 | 59 | ## Laravel 60 | All custom sniffs specific to Laravel. 61 | 62 | ### Config filename kebab case 63 | Checks if all config files are written in kebab case. 64 | 65 | ### Disallow env usage 66 | Makes sure that you don't use `env` helper in your code, except for config files. 67 | 68 | ### Event listener suffix 69 | Enforces event listeners to end with a specific suffix, this suffix is defaulted to `Listener`. 70 | 71 | | parameters | defaults | 72 | | --- | --- | 73 | | suffix | Listener | 74 | 75 | ### Disallow blade outside of the `resources` directory 76 | Makes sure no `.blade.php` files exist outside of Laravel's `resources` directory. 77 | 78 | | parameters | defaults | 79 | | --- | --- | 80 | | resourcesDirectory | {YOUR_PROJECT}/resources | 81 | 82 | ## PhpDoc 83 | All custom sniffs which are not specific to Laravel. 84 | 85 | ### Property dollar sign 86 | Makes sure that you always have a dollar sign in your properties defined in phpdoc. 87 | ```php 88 | /** 89 | * @property string $name 90 | */ 91 | class User {} 92 | ``` 93 | 94 | ### Param tags with no type or comment 95 | This removes all `@param` tags which has no specified a type or comment 96 | ```php 97 | /** 98 | * @param string $name 99 | * @param $type some random type 100 | * @param $other // <-- will be removed 101 | */ 102 | public function someMethod($name, $type, $other): void 103 | {} 104 | ``` 105 | 106 | 107 | This is mainly because phpstan requires this before it sees the property as valid. 108 | -------------------------------------------------------------------------------- /src/WorksomeSniff/Support/PropertyDoc.php: -------------------------------------------------------------------------------- 1 | after($types)->trim(); 35 | 36 | if ($remainderAfterType->isNotEmpty()) { 37 | $variable = $remainderAfterType->before(' ')->__toString(); 38 | $description = $remainderAfterType->after(' ')->__toString(); 39 | } else { 40 | $types = null; 41 | $variable = $typeMatches[0]; 42 | $description = null; 43 | } 44 | 45 | if (str_starts_with($types ?? '', '$')) { 46 | $description = $variable; 47 | $variable = $types; 48 | $types = null; 49 | } 50 | 51 | return new self( 52 | $scope, 53 | $types, 54 | $variable, 55 | $description !== $variable ? $description : null, 56 | ); 57 | } 58 | 59 | public function variableHasDollarSymbol(): bool 60 | { 61 | return str_starts_with($this->variableName, '$'); 62 | } 63 | 64 | public function joined(): string 65 | { 66 | return join(' ', array_filter([ 67 | $this->scope, 68 | $this->types, 69 | Str::start($this->variableName, '$'), 70 | $this->description, 71 | ])); 72 | } 73 | 74 | private static function regexForTypes(): string 75 | { 76 | return <<]+|(?2))*+ # Recursively ignore any matching sets of '<' and '>', '{' and '}' or '[' and ']' found in nested types. Eg: `Collection>`. 83 | [\]}>] # Until we match the closing '>' or '}'. 84 | )? # End capture group #2. Not all types are generics, so capture group 2 is optional. 85 | ) # End capture group #1. 86 | ( # Capture group #3. 87 | (?:\s?\|\s?)(?1) # If we find an '|' symbol we expect another type, so we'll recursively check capture group #1 again. 88 | )* # End capture group #3. There can be 0 or more of this group. 89 | /x 90 | REGEXP; 91 | } 92 | } -------------------------------------------------------------------------------- /src/WorksomeSniff/ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Worksome Coding Standard 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | error 52 | 53 | 54 | 55 | 56 | error 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | --------------------------------------------------------------------------------