├── LICENCE ├── README.md ├── SECURITY.md ├── UPGRADE.md ├── bin └── twig-cs-fixer ├── composer.json └── src ├── Cache ├── Cache.php ├── CacheEncoder.php ├── FileHandler │ ├── CacheFileHandler.php │ └── CacheFileHandlerInterface.php ├── Manager │ ├── CacheManagerInterface.php │ ├── FileCacheManager.php │ └── NullCacheManager.php └── Signature.php ├── Config ├── Config.php └── ConfigResolver.php ├── Console ├── Application.php └── Command │ └── TwigCsFixerCommand.php ├── Environment ├── Parser │ └── ComponentTokenParser.php └── StubbedEnvironment.php ├── Exception ├── CannotFixFileException.php ├── CannotResolveConfigException.php ├── CannotTokenizeException.php └── CannotWriteCacheException.php ├── File ├── FileHelper.php └── Finder.php ├── Report ├── Report.php ├── Reporter │ ├── CheckstyleReporter.php │ ├── GithubReporter.php │ ├── GitlabReporter.php │ ├── JUnitReporter.php │ ├── NullReporter.php │ ├── ReporterInterface.php │ └── TextReporter.php ├── ReporterFactory.php ├── Violation.php └── ViolationId.php ├── Rules ├── AbstractFixableRule.php ├── AbstractRule.php ├── AbstractSpacingRule.php ├── ConfigurableRuleInterface.php ├── Delimiter │ ├── BlockNameSpacingRule.php │ └── DelimiterSpacingRule.php ├── File │ ├── DirectoryNameRule.php │ ├── FileExtensionRule.php │ └── FileNameRule.php ├── FixableRuleInterface.php ├── Function │ ├── IncludeFunctionRule.php │ ├── MacroArgumentNameRule.php │ ├── NamedArgumentNameRule.php │ ├── NamedArgumentSeparatorRule.php │ └── NamedArgumentSpacingRule.php ├── Literal │ ├── CompactHashRule.php │ ├── HashQuoteRule.php │ └── SingleQuoteRule.php ├── Node │ ├── AbstractNodeRule.php │ ├── ForbiddenBlockRule.php │ ├── ForbiddenFilterRule.php │ ├── ForbiddenFunctionRule.php │ ├── NodeRuleInterface.php │ └── ValidConstantFunctionRule.php ├── Operator │ ├── OperatorNameSpacingRule.php │ └── OperatorSpacingRule.php ├── Punctuation │ ├── PunctuationSpacingRule.php │ ├── TrailingCommaMultiLineRule.php │ └── TrailingCommaSingleLineRule.php ├── RuleInterface.php ├── RuleTrait.php ├── Variable │ └── VariableNameRule.php └── Whitespace │ ├── BlankEOFRule.php │ ├── EmptyLinesRule.php │ ├── IndentRule.php │ └── TrailingSpaceRule.php ├── Ruleset └── Ruleset.php ├── Runner ├── Fixer.php ├── FixerInterface.php └── Linter.php ├── Standard ├── StandardInterface.php ├── Symfony.php ├── Twig.php └── TwigCsFixer.php ├── Test ├── AbstractRuleTestCase.php └── TestHelper.php ├── Token ├── Token.php ├── Tokenizer.php ├── TokenizerInterface.php └── Tokens.php └── Util └── StringUtil.php /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021+ Vincent Langlet 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twig CS Fixer 2 | 3 | [![PHP Version](https://poser.pugx.org/vincentlanglet/twig-cs-fixer/require/php)](https://packagist.org/packages/vincentlanglet/twig-cs-fixer) 4 | [![Latest Stable Version](https://poser.pugx.org/vincentlanglet/twig-cs-fixer/v)](https://github.com/VincentLanglet/Twig-CS-Fixer/releases/latest) 5 | [![License](https://poser.pugx.org/vincentlanglet/twig-cs-fixer/license)](https://github.com/VincentLanglet/Twig-CS-Fixer/blob/main/LICENCE) 6 | [![Actions Status](https://github.com/VincentLanglet/Twig-CS-Fixer/workflows/Test/badge.svg)](https://github.com/RobDWaller/csp-generator/actions) 7 | [![Coverage](https://codecov.io/gh/VincentLanglet/Twig-CS-Fixer/branch/main/graph/badge.svg)](https://codecov.io/gh/VincentLanglet/Twig-CS-Fixer/branch/main) 8 | [![Infection MSI](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2FVincentLanglet%2FTwig-CS-Fixer%2Fmain)](https://dashboard.stryker-mutator.io/reports/github.com/VincentLanglet/Twig-CS-Fixer/main) 9 | 10 | ## Installation 11 | 12 | ### From composer 13 | 14 | This tool can be installed with [Composer](https://getcomposer.org/). 15 | 16 | Add the package as a dependency of your project 17 | 18 | ```bash 19 | composer require --dev vincentlanglet/twig-cs-fixer 20 | ``` 21 | 22 | Then, use it! 23 | 24 | ```bash 25 | vendor/bin/twig-cs-fixer lint /path/to/code 26 | vendor/bin/twig-cs-fixer lint --fix /path/to/code 27 | ``` 28 | 29 | > [!NOTE] 30 | > Although [bin-dependencies may have composer conflicts](https://github.com/bamarni/composer-bin-plugin#why-a-hard-problem-with-a-simple-solution), 31 | > this is the recommended way because it will autoload everything you need. 32 | 33 | ### As a PHAR 34 | 35 | You can always fetch the stable version as a Phar archive through the following 36 | link with the `VERSION` you're looking for: 37 | 38 | ```bash 39 | wget -c https://github.com/VincentLanglet/Twig-CS-Fixer/releases/download/VERSION/twig-cs-fixer.phar 40 | ``` 41 | 42 | The PHAR files are signed with a public key which can be queried at 43 | `keys.openpgp.org` with the id `AC0E7FD8858D80003AA88FF8DEBB71EDE9601234`. 44 | 45 | > [!TIP] 46 | > You will certainly need to add 47 | > ```php 48 | > require_once __DIR__.'/vendor/autoload.php'; 49 | > ``` 50 | > in your [config file](docs/configuration.md) in order to: 51 | > - Use existing [node based rules](docs/configuration.md#node-based-rules). 52 | > - Write your own custom rules. 53 | 54 | ## Twig Coding Standard Rules 55 | 56 | From the [official one](https://twig.symfony.com/doc/3.x/coding_standards.html). 57 | 58 | ### Delimiter spacing 59 | 60 | Ensures there is a single space after a delimiter opening (`{{`, `{%` and `{#`) 61 | and before a delimiter closing (`}}`, `%}` and `#}`). 62 | 63 | When using a whitespace control character, do not put any spaces between it and the delimiter. 64 | 65 | ### Operator spacing 66 | 67 | Ensures there is a single space before and after the following operators: 68 | comparison operators (`==`, `!=`, `<`, `>`, `>=`, `<=`), math operators (`+`, `-`, `/`, `*`, `%`, `//`, `**`), 69 | logic operators (`not`, `and`, `or`), `~`, `is`, `in`, and the ternary operator (`?:`). 70 | 71 | Removes any space before and after the `..` operator. 72 | 73 | ### Punctuation spacing 74 | 75 | Ensures there is a single space after `:` in hashes and `,` in arrays and hashes. 76 | 77 | Removes any space after an opening parenthesis and before a closing parenthesis in expressions. 78 | 79 | Removes any space before and after the following operators: `|`, `.`, `[]`. 80 | 81 | Removes any space before and after parenthesis in filter and function calls. 82 | 83 | Removes any space before and after opening and closing of arrays and hashes. 84 | 85 | ### Macro & Function/Filter/Test 86 | 87 | Ensures there is a single space before and after `=` in macro argument declarations. 88 | 89 | Ensures there is no space before and after `=` sign when using named arguments. 90 | 91 | Ensures one space after the `:` sign when using named arguments. 92 | 93 | Use `:` instead of `=` to separate argument names and values. 94 | 95 | ### Naming 96 | 97 | Use snake case for all variable names. 98 | 99 | Use snake case for all argument names. 100 | 101 | Use snake case for all named arguments. 102 | 103 | ## Custom configuration 104 | 105 | By default, the twig-cs-fixer standard is enabled with the twig coding standard rules and some extra rules. 106 | This tool also provides a standard with only the twig rules 107 | and another standard with extra rules from the symfony coding standards. 108 | 109 | Everything is configurable, so take a look at the following documentation: 110 | - [CLI options](docs/command.md) 111 | - [Configuration file](docs/configuration.md) 112 | - [How to disable a rule on a specific file or line](docs/identifiers.md) 113 | - [Rules & Standard](docs/rules.md) 114 | - [How to write a custom rule](docs/custom.md) 115 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest major version is supported with security updates. 6 | 7 | | Version | Supported | 8 | |---------|--------------------| 9 | | 3.x | :white_check_mark: | 10 | | 2.x | :x: | 11 | | 1.x | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | To report a vulnerability, please follow [https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) 16 | 17 | Once your report is received, the project maintainers will review it and respond accordingly. We appreciate your responsible disclosure and will make every effort to address the issue in a timely manner. 18 | 19 | Thank you for helping us maintain the security of Twig-CS-Fixer! 20 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | UPGRADE FROM 2.x to 3.0 2 | ======================= 3 | 4 | - The `checkstyle` and `junit` reporter now try to use absolute path rather than relative path. 5 | - In debug mode, the report now contains both the identifier and the message of the error. 6 | - The position of `TrailingCommaMultiLineRule` error changed. 7 | - The position of `TrailingCommaSingleLineRule` error changed. 8 | - `TwigCsFixer\Command\TwigCsFixerCommand` class moved to `TwigCsFixer\Console\Command` folder. 9 | - `TwigCsFixer\Report\Reporter\ReporterInterface` now require a `getName` method. 10 | 11 | If you never implemented a custom rule, nothing else changed. Otherwise, ... 12 | 13 | ### AbstractRule 14 | 15 | ```diff 16 | - $this->isTokenMatching($token, $type, $value) 17 | + $token->isTokenMatching($type, $value) 18 | ``` 19 | 20 | ```diff 21 | - $this->findNext($type, $tokens, $start) 22 | + $tokens->findNext($type, $start) 23 | 24 | - $this->findNext($type, $tokens, $start, true) 25 | + $tokens->findNext($type, $start, null, true) 26 | ``` 27 | 28 | ```diff 29 | - $this->findPrevious($type, $tokens, $start) 30 | + $tokens->findPrevious($type, $start) 31 | 32 | - $this->findPrevious($type, $tokens, $start, true) 33 | + $tokens->findPrevious($type, $start, null, true) 34 | ``` 35 | 36 | ```diff 37 | - protected function process(int $tokenPosition, array $tokens): void; 38 | + protected function process(int $tokenIndex, Tokens $tokens): ?int; 39 | ``` 40 | 41 | ### AbstractSpacingRule 42 | 43 | ```diff 44 | - protected function getSpaceAfter(int $tokenPosition, array $tokens): ?int; 45 | + protected function getSpaceAfter(int $tokenIndex, Tokens $tokens): ?int; 46 | ``` 47 | 48 | ```diff 49 | - protected function getSpaceBefore(int $tokenPosition, array $tokens): ?int; 50 | + protected function getSpaceBefore(int $tokenIndex, Tokens $tokens): ?int; 51 | ``` 52 | 53 | ### RuleInterface 54 | 55 | ```diff 56 | - public function lintFile(array $tokens, Report $report, array $ignoredViolations = []): void; 57 | + public function lintFile(Tokens $tokens, Report $report): void; 58 | ``` 59 | 60 | ### FixableRuleInterface 61 | 62 | ```diff 63 | - public function fixFile(array $tokens, FixerInterface $fixer, array $ignoredViolations = []): void; 64 | + public function fixFile(Tokens $tokens, FixerInterface $fixer): void; 65 | ``` 66 | 67 | ### TokenizerInterface 68 | 69 | ```diff 70 | - /** 71 | - * @return array{list, list} 72 | - */ 73 | - public function tokenize(Source $source): array; 74 | + public function tokenize(Source $source): Tokens; 75 | ``` 76 | 77 | ### Token 78 | 79 | The `Token::NAME_TYPE` has been split in four: 80 | - `Token::FILTER_NAME_TYPE` 81 | - `Token::FUNCTION_NAME_TYPE` 82 | - `Token::TEST_NAME_TYPE` 83 | - `Token::NAME_TYPE` 84 | 85 | ```diff 86 | - $token->getPosition(); 87 | + $token->getLinePosition(); 88 | ``` 89 | 90 | ### Directory 91 | 92 | ```diff 93 | - (new Directory($dir))->getRelativePathTo($file); 94 | + FileHelper::getRelativePath($file, $dir); 95 | ``` 96 | -------------------------------------------------------------------------------- /bin/twig-cs-fixer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add($command); 24 | $application->setDefaultCommand($command->getName()); 25 | $application->run(); 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vincentlanglet/twig-cs-fixer", 3 | "description": "A tool to automatically fix Twig code style", 4 | "license": "MIT", 5 | "type": "coding-standard", 6 | "authors": [ 7 | { 8 | "name": "Vincent Langlet" 9 | } 10 | ], 11 | "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", 12 | "require": { 13 | "php": ">=8.0", 14 | "ext-ctype": "*", 15 | "ext-json": "*", 16 | "composer-runtime-api": "^2.0.0", 17 | "symfony/console": "^5.4.9 || ^6.4 || ^7.0", 18 | "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", 19 | "symfony/finder": "^5.4 || ^6.4 || ^7.0", 20 | "symfony/string": "^5.4.42 || ^6.4.10 || ~7.0.10 || ^7.1.3", 21 | "twig/twig": "^3.4", 22 | "webmozart/assert": "^1.10" 23 | }, 24 | "require-dev": { 25 | "composer/semver": "^3.2.0", 26 | "dereuromark/composer-prefer-lowest": "^0.1.10", 27 | "ergebnis/composer-normalize": "^2.29", 28 | "friendsofphp/php-cs-fixer": "^3.13.0", 29 | "infection/infection": "^0.26.16 || ^0.29.14", 30 | "phpstan/phpstan": "^2.0", 31 | "phpstan/phpstan-phpunit": "^2.0", 32 | "phpstan/phpstan-strict-rules": "^2.0", 33 | "phpstan/phpstan-symfony": "^2.0", 34 | "phpstan/phpstan-webmozart-assert": "^2.0", 35 | "phpunit/phpunit": "^9.5.26 || ^11.5.18 || ^12.1.3", 36 | "rector/rector": "^2.0.0", 37 | "shipmonk/composer-dependency-analyser": "^1.6", 38 | "symfony/process": "^5.4 || ^6.4 || ^7.0", 39 | "symfony/twig-bridge": "^5.4 || ^6.4 || ^7.0", 40 | "symfony/ux-twig-component": "^2.2.0", 41 | "twig/cache-extra": "^3.2" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "TwigCsFixer\\": "src/" 46 | } 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "TwigCsFixer\\PHPStan\\": ".phpstan/", 51 | "TwigCsFixer\\Tests\\": "tests/" 52 | } 53 | }, 54 | "bin": [ 55 | "bin/twig-cs-fixer" 56 | ], 57 | "config": { 58 | "allow-plugins": { 59 | "ergebnis/composer-normalize": true, 60 | "infection/extension-installer": true 61 | }, 62 | "sort-packages": true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Cache/Cache.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $hashes = []; 13 | 14 | public function __construct(private Signature $signature) 15 | { 16 | } 17 | 18 | /** 19 | * @return array 20 | */ 21 | public function getHashes(): array 22 | { 23 | return $this->hashes; 24 | } 25 | 26 | public function getSignature(): Signature 27 | { 28 | return $this->signature; 29 | } 30 | 31 | public function has(string $file): bool 32 | { 33 | return \array_key_exists($file, $this->hashes); 34 | } 35 | 36 | public function get(string $file): string 37 | { 38 | if (!$this->has($file)) { 39 | throw new \InvalidArgumentException(\sprintf('The file "%s" is not cached', $file)); 40 | } 41 | 42 | return $this->hashes[$file]; 43 | } 44 | 45 | public function set(string $file, string $hash): void 46 | { 47 | $this->hashes[$file] = $hash; 48 | } 49 | 50 | public function clear(string $file): void 51 | { 52 | unset($this->hashes[$file]); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Cache/CacheEncoder.php: -------------------------------------------------------------------------------- 1 | getMessage() 23 | )); 24 | } 25 | 26 | Assert::isArray($data); 27 | 28 | Assert::keyExists($data, 'php_version'); 29 | Assert::string($data['php_version']); 30 | Assert::keyExists($data, 'fixer_version'); 31 | Assert::string($data['fixer_version']); 32 | Assert::keyExists($data, 'rules'); 33 | Assert::isArray($data['rules']); 34 | 35 | $signature = new Signature( 36 | $data['php_version'], 37 | $data['fixer_version'], 38 | $data['rules'], 39 | ); 40 | 41 | $cache = new Cache($signature); 42 | 43 | Assert::keyExists($data, 'hashes'); 44 | Assert::isArray($data['hashes']); 45 | foreach ($data['hashes'] as $file => $hash) { 46 | Assert::string($file); 47 | Assert::string($hash); 48 | 49 | $cache->set($file, $hash); 50 | } 51 | 52 | return $cache; 53 | } 54 | 55 | /** 56 | * @throws \JsonException 57 | */ 58 | public static function toJson(Cache $cache): string 59 | { 60 | $signature = $cache->getSignature(); 61 | 62 | return json_encode([ 63 | 'php_version' => $signature->getPhpVersion(), 64 | 'fixer_version' => $signature->getFixerVersion(), 65 | 'rules' => $signature->getRules(), 66 | 'hashes' => $cache->getHashes(), 67 | ], \JSON_THROW_ON_ERROR); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Cache/FileHandler/CacheFileHandler.php: -------------------------------------------------------------------------------- 1 | file; 20 | } 21 | 22 | public function read(): ?Cache 23 | { 24 | if (!file_exists($this->file)) { 25 | return null; 26 | } 27 | 28 | $content = @file_get_contents($this->file); 29 | if (false === $content) { 30 | return null; 31 | } 32 | 33 | try { 34 | return CacheEncoder::fromJson($content); 35 | } catch (\InvalidArgumentException) { 36 | return null; 37 | } 38 | } 39 | 40 | public function write(Cache $cache): void 41 | { 42 | if (file_exists($this->file)) { 43 | if (is_dir($this->file)) { 44 | throw CannotWriteCacheException::locationIsDirectory($this->file); 45 | } 46 | 47 | if (!is_writable($this->file)) { 48 | throw CannotWriteCacheException::locationIsNotWritable($this->file); 49 | } 50 | } else { 51 | $dir = \dirname($this->file); 52 | 53 | if (!is_dir($dir) && !@mkdir($dir, recursive: true)) { 54 | throw CannotWriteCacheException::missingDirectory($this->file); 55 | } 56 | 57 | @touch($this->file); 58 | @chmod($this->file, 0666); 59 | } 60 | 61 | try { 62 | file_put_contents($this->file, CacheEncoder::toJson($cache)); 63 | } catch (\JsonException $exception) { 64 | throw CannotWriteCacheException::jsonException($exception); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Cache/FileHandler/CacheFileHandlerInterface.php: -------------------------------------------------------------------------------- 1 | cacheDirectory = \dirname($handler->getFile()); 36 | 37 | $this->readCache(); 38 | } 39 | 40 | /** 41 | * @throws CannotWriteCacheException 42 | */ 43 | public function __destruct() 44 | { 45 | $this->writeCache(); 46 | } 47 | 48 | /** 49 | * This class is not intended to be serialized, 50 | * and cannot be deserialized (see __wakeup method). 51 | */ 52 | public function __sleep(): array 53 | { 54 | throw new \BadMethodCallException(\sprintf('Cannot serialize %s.', self::class)); 55 | } 56 | 57 | /** 58 | * Disable the deserialization of the class to prevent attacker executing 59 | * code by leveraging the __destruct method. 60 | * 61 | * @see https://owasp.org/www-community/vulnerabilities/PHP_Object_Injection 62 | */ 63 | public function __wakeup(): void 64 | { 65 | throw new \BadMethodCallException(\sprintf('Cannot unserialize %s.', self::class)); 66 | } 67 | 68 | public function needFixing(string $file, string $fileContent): bool 69 | { 70 | $file = FileHelper::getRelativePath($file, $this->cacheDirectory); 71 | 72 | return !$this->cache->has($file) || $this->cache->get($file) !== $this->calcHash($fileContent); 73 | } 74 | 75 | public function setFile(string $file, string $fileContent): void 76 | { 77 | $file = FileHelper::getRelativePath($file, $this->cacheDirectory); 78 | 79 | $hash = $this->calcHash($fileContent); 80 | 81 | $this->cache->set($file, $hash); 82 | } 83 | 84 | private function readCache(): void 85 | { 86 | $cache = $this->handler->read(); 87 | 88 | if (null === $cache || !$this->signature->equals($cache->getSignature())) { 89 | $cache = new Cache($this->signature); 90 | } 91 | 92 | $this->cache = $cache; 93 | } 94 | 95 | /** 96 | * @throws CannotWriteCacheException 97 | */ 98 | private function writeCache(): void 99 | { 100 | $this->handler->write($this->cache); 101 | } 102 | 103 | private function calcHash(string $content): string 104 | { 105 | return md5($content); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Cache/Manager/NullCacheManager.php: -------------------------------------------------------------------------------- 1 | $rules 14 | */ 15 | public function __construct( 16 | private string $phpVersion, 17 | private string $fixerVersion, 18 | private array $rules, 19 | ) { 20 | } 21 | 22 | public static function fromRuleset( 23 | string $phpVersion, 24 | string $fixerVersion, 25 | Ruleset $ruleset, 26 | ): self { 27 | $rules = []; 28 | foreach ($ruleset->getRules() as $rule) { 29 | $rules[$rule::class] = $rule instanceof ConfigurableRuleInterface 30 | ? $rule->getConfiguration() 31 | : null; 32 | } 33 | 34 | return new self($phpVersion, $fixerVersion, $rules); 35 | } 36 | 37 | public function getPhpVersion(): string 38 | { 39 | return $this->phpVersion; 40 | } 41 | 42 | public function getFixerVersion(): string 43 | { 44 | return $this->fixerVersion; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function getRules(): array 51 | { 52 | return $this->rules; 53 | } 54 | 55 | public function equals(self $signature): bool 56 | { 57 | return $this->phpVersion === $signature->getPhpVersion() 58 | && $this->fixerVersion === $signature->getFixerVersion() 59 | && $this->rules === $signature->getRules(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Config/Config.php: -------------------------------------------------------------------------------- 1 | 36 | */ 37 | private array $customReporters = []; 38 | 39 | /** 40 | * @var list 41 | */ 42 | private array $twigExtensions = []; 43 | 44 | /** 45 | * @var list 46 | */ 47 | private array $tokenParsers = []; 48 | 49 | /** 50 | * @var list 51 | */ 52 | private array $nodeVisitors = []; 53 | 54 | private bool $allowNonFixableRules = false; 55 | 56 | public function __construct(private string $name = 'Default') 57 | { 58 | $this->ruleset = new Ruleset(); 59 | $this->ruleset->addStandard(new TwigCsFixer()); 60 | $this->finder = new TwigCsFinder(); 61 | } 62 | 63 | public function getName(): string 64 | { 65 | return $this->name; 66 | } 67 | 68 | public function getRuleset(): Ruleset 69 | { 70 | return $this->ruleset; 71 | } 72 | 73 | /** 74 | * @return $this 75 | */ 76 | public function setRuleset(Ruleset $ruleset): self 77 | { 78 | $this->ruleset = $ruleset; 79 | 80 | return $this; 81 | } 82 | 83 | public function getFinder(): Finder 84 | { 85 | return $this->finder; 86 | } 87 | 88 | /** 89 | * @return $this 90 | */ 91 | public function setFinder(Finder $finder): self 92 | { 93 | $this->finder = $finder; 94 | 95 | return $this; 96 | } 97 | 98 | public function getCacheManager(): ?CacheManagerInterface 99 | { 100 | return $this->cacheManager; 101 | } 102 | 103 | /** 104 | * @return $this 105 | */ 106 | public function setCacheManager(?CacheManagerInterface $cacheManager): self 107 | { 108 | $this->cacheManager = $cacheManager; 109 | 110 | return $this; 111 | } 112 | 113 | public function getCacheFile(): ?string 114 | { 115 | return $this->cacheFile; 116 | } 117 | 118 | /** 119 | * @return $this 120 | */ 121 | public function setCacheFile(?string $cacheFile): self 122 | { 123 | $this->cacheFile = $cacheFile; 124 | 125 | return $this; 126 | } 127 | 128 | public function addCustomReporter(ReporterInterface $reporter): self 129 | { 130 | $this->customReporters[] = $reporter; 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * @return list 137 | */ 138 | public function getCustomReporters(): array 139 | { 140 | return $this->customReporters; 141 | } 142 | 143 | /** 144 | * @return $this 145 | */ 146 | public function addTwigExtension(ExtensionInterface $extension): self 147 | { 148 | $this->twigExtensions[] = $extension; 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * @return list 155 | */ 156 | public function getTwigExtensions(): array 157 | { 158 | return $this->twigExtensions; 159 | } 160 | 161 | /** 162 | * @return $this 163 | */ 164 | public function addTokenParser(TokenParserInterface $tokenParser): self 165 | { 166 | $this->tokenParsers[] = $tokenParser; 167 | 168 | return $this; 169 | } 170 | 171 | /** 172 | * @return list 173 | */ 174 | public function getTokenParsers(): array 175 | { 176 | return $this->tokenParsers; 177 | } 178 | 179 | /** 180 | * @return $this 181 | */ 182 | public function addNodeVisitor(NodeVisitorInterface $nodeVisitor): self 183 | { 184 | $this->nodeVisitors[] = $nodeVisitor; 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * @return list 191 | */ 192 | public function getNodeVisitors(): array 193 | { 194 | return $this->nodeVisitors; 195 | } 196 | 197 | /** 198 | * @return $this 199 | */ 200 | public function allowNonFixableRules(bool $allowNonFixableRules = true): self 201 | { 202 | $this->allowNonFixableRules = $allowNonFixableRules; 203 | 204 | return $this; 205 | } 206 | 207 | public function areNonFixableRulesAllowed(): bool 208 | { 209 | return $this->allowNonFixableRules; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Config/ConfigResolver.php: -------------------------------------------------------------------------------- 1 | getConfig($configPath); 39 | $config->setFinder($this->resolveFinder($config->getFinder(), $paths)); 40 | 41 | // Override ruleset with config 42 | $config->getRuleset()->allowNonFixableRules($config->areNonFixableRulesAllowed()); 43 | 44 | if ($disableCache) { 45 | $config->setCacheFile(null); 46 | $config->setCacheManager(null); 47 | } else { 48 | $config->setCacheManager($this->resolveCacheManager( 49 | $config->getCacheManager(), 50 | $config->getCacheFile(), 51 | $config->getRuleset(), 52 | )); 53 | } 54 | 55 | return $config; 56 | } 57 | 58 | /** 59 | * @throws CannotResolveConfigException 60 | */ 61 | private function getConfig(?string $configPath = null): Config 62 | { 63 | if (null !== $configPath) { 64 | return $this->getConfigFromPath(FileHelper::getAbsolutePath($configPath, $this->workingDir)); 65 | } 66 | 67 | $defaultPath = FileHelper::getAbsolutePath(Config::DEFAULT_PATH, $this->workingDir); 68 | if (file_exists($defaultPath)) { 69 | return $this->getConfigFromPath($defaultPath); 70 | } 71 | 72 | $defaultDistPath = FileHelper::getAbsolutePath(Config::DEFAULT_DIST_PATH, $this->workingDir); 73 | if (file_exists($defaultDistPath)) { 74 | return $this->getConfigFromPath($defaultDistPath); 75 | } 76 | 77 | return new Config(); 78 | } 79 | 80 | /** 81 | * @throws CannotResolveConfigException 82 | */ 83 | private function getConfigFromPath(string $configPath): Config 84 | { 85 | if (!is_file($configPath)) { 86 | throw CannotResolveConfigException::fileNotFound($configPath); 87 | } 88 | 89 | $config = require $configPath; 90 | if (!$config instanceof Config) { 91 | throw CannotResolveConfigException::fileMustReturnConfig($configPath); 92 | } 93 | 94 | return $config; 95 | } 96 | 97 | /** 98 | * @param string[] $paths 99 | */ 100 | private function resolveFinder(Finder $finder, array $paths): Finder 101 | { 102 | $nestedFinder = null; 103 | try { 104 | $nestedFinder = $finder->getIterator(); 105 | } catch (\LogicException) { 106 | // Only way to know if in() method has not been called 107 | } 108 | 109 | if ([] === $paths) { 110 | if (null === $nestedFinder) { 111 | return $finder->in('./'); 112 | } 113 | 114 | return $finder; 115 | } 116 | 117 | $files = []; 118 | $directories = []; 119 | foreach ($paths as $path) { 120 | if (is_file($path)) { 121 | $files[] = $path; 122 | } else { 123 | $directories[] = $path; 124 | } 125 | } 126 | 127 | if (null === $nestedFinder) { 128 | return $finder->in($directories)->append($files); 129 | } 130 | 131 | return TwigCsFinder::create()->in($directories)->append($files); 132 | } 133 | 134 | private function resolveCacheManager( 135 | ?CacheManagerInterface $cacheManager, 136 | ?string $cacheFile, 137 | Ruleset $ruleset, 138 | ): ?CacheManagerInterface { 139 | if (null !== $cacheManager) { 140 | return $cacheManager; 141 | } 142 | 143 | if (null === $cacheFile) { 144 | return null; 145 | } 146 | 147 | return new FileCacheManager( 148 | new CacheFileHandler(FileHelper::getAbsolutePath($cacheFile, $this->workingDir)), 149 | Signature::fromRuleset( 150 | \PHP_VERSION, 151 | InstalledVersions::getReference(Application::PACKAGE_NAME) ?? '0', 152 | $ruleset, 153 | ) 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Console/Application.php: -------------------------------------------------------------------------------- 1 | getPackageVersion($package)); 18 | } 19 | 20 | private function getPackageVersion(string $package): string 21 | { 22 | foreach (InstalledVersions::getAllRawData() as $installed) { 23 | if (!isset($installed['versions'][$package])) { 24 | continue; 25 | } 26 | 27 | $version = $installed['versions'][$package]['pretty_version'] ?? 'dev'; 28 | $reference = $installed['versions'][$package]['reference'] ?? null; 29 | if (null === $reference) { 30 | return $version; 31 | } 32 | 33 | return \sprintf('%s@%s', $version, substr($reference, 0, 7)); 34 | } 35 | 36 | return 'UNKNOWN'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Console/Command/TwigCsFixerCommand.php: -------------------------------------------------------------------------------- 1 | setName('lint') 33 | ->setAliases(['check', 'fix']) 34 | ->setDescription('Lints a template and outputs encountered errors') 35 | ->setDefinition([ 36 | new InputArgument( 37 | 'paths', 38 | InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 39 | 'Paths of files and folders to parse' 40 | ), 41 | new InputOption( 42 | 'level', 43 | 'l', 44 | InputOption::VALUE_REQUIRED, 45 | 'Allowed values are notice, warning or error', 46 | Report::MESSAGE_TYPE_NOTICE 47 | ), 48 | new InputOption( 49 | 'config', 50 | 'c', 51 | InputOption::VALUE_REQUIRED, 52 | 'Path to a `.twig-cs-fixer.php` config file' 53 | ), 54 | new InputOption( 55 | 'report', 56 | 'r', 57 | InputOption::VALUE_REQUIRED, 58 | 'Report format', 59 | TextReporter::NAME 60 | ), 61 | new InputOption( 62 | 'fix', 63 | 'f', 64 | InputOption::VALUE_NONE, 65 | 'Automatically fix all the fixable violations' 66 | ), 67 | new InputOption( 68 | 'no-cache', 69 | '', 70 | InputOption::VALUE_NONE, 71 | 'Disable cache while running the fixer' 72 | ), 73 | new InputOption( 74 | 'debug', 75 | '', 76 | InputOption::VALUE_NONE, 77 | 'Display error identifiers instead of messages', 78 | ), 79 | ]) 80 | ; 81 | } 82 | 83 | protected function initialize(InputInterface $input, OutputInterface $output): void 84 | { 85 | if ($input->hasArgument('command') && 'fix' === $input->getArgument('command')) { 86 | $input->setOption('fix', true); 87 | } 88 | } 89 | 90 | protected function execute(InputInterface $input, OutputInterface $output): int 91 | { 92 | try { 93 | $config = $this->resolveConfig($input, $output); 94 | $report = $this->runLinter($config, $input, $output); 95 | } catch (\Throwable $exception) { 96 | $output->writeln(\sprintf('Error: %s', $exception->getMessage())); 97 | 98 | return self::INVALID; 99 | } 100 | 101 | return 0 === $report->getTotalErrors() ? self::SUCCESS : self::FAILURE; 102 | } 103 | 104 | /** 105 | * @throws CannotResolveConfigException 106 | */ 107 | private function resolveConfig(InputInterface $input, OutputInterface $output): Config 108 | { 109 | $workingDir = @getcwd(); 110 | Assert::notFalse($workingDir, 'Cannot get the current working directory.'); 111 | 112 | $configResolver = new ConfigResolver($workingDir); 113 | 114 | $config = $configResolver->resolveConfig( 115 | $input->getArgument('paths'), 116 | $input->getOption('config'), 117 | $input->getOption('no-cache'), 118 | ); 119 | 120 | $cacheFile = $config->getCacheFile(); 121 | if (null !== $cacheFile && is_file($cacheFile)) { 122 | $output->writeln(\sprintf('Using cache file "%s".', $cacheFile), OutputInterface::VERBOSITY_DEBUG); 123 | } 124 | 125 | return $config; 126 | } 127 | 128 | private function runLinter(Config $config, InputInterface $input, OutputInterface $output): Report 129 | { 130 | $twig = new StubbedEnvironment( 131 | $config->getTwigExtensions(), 132 | $config->getTokenParsers(), 133 | $config->getNodeVisitors() 134 | ); 135 | $tokenizer = new Tokenizer($twig); 136 | $linter = new Linter($twig, $tokenizer, $config->getCacheManager()); 137 | 138 | $report = $linter->run( 139 | $config->getFinder(), 140 | $config->getRuleset(), 141 | $input->getOption('fix') ? new Fixer($tokenizer) : null, 142 | ); 143 | 144 | $reporterFactory = new ReporterFactory($config->getCustomReporters()); 145 | $reporter = $reporterFactory->getReporter($input->getOption('report')); 146 | $reporter->display($output, $report, $input->getOption('level'), $input->getOption('debug')); 147 | 148 | return $report; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Environment/Parser/ComponentTokenParser.php: -------------------------------------------------------------------------------- 1 | parser->getStream(); 19 | 20 | $this->parser->getExpressionParser()->parseExpression(); 21 | $this->parseArguments(); 22 | 23 | $fakeParentToken = new Token(Token::STRING_TYPE, '__parent__', $token->getLine()); 24 | 25 | // inject a fake parent to make the parent() function work 26 | $stream->injectTokens([ 27 | new Token(Token::BLOCK_START_TYPE, '', $token->getLine()), 28 | new Token(Token::NAME_TYPE, 'extends', $token->getLine()), 29 | $fakeParentToken, 30 | new Token(Token::BLOCK_END_TYPE, '', $token->getLine()), 31 | ]); 32 | 33 | $this->parser->parse($stream, fn (Token $token) => $token->test("end{$this->getTag()}"), true); 34 | 35 | $stream->expect(Token::BLOCK_END_TYPE); 36 | 37 | return new Node(); 38 | } 39 | 40 | public function getTag(): string 41 | { 42 | return 'component'; 43 | } 44 | 45 | private function parseArguments(): void 46 | { 47 | $stream = $this->parser->getStream(); 48 | 49 | if (null !== $stream->nextIf(Token::NAME_TYPE, 'with')) { 50 | $this->parser->getExpressionParser()->parseExpression(); 51 | } 52 | 53 | $stream->nextIf(Token::NAME_TYPE, 'only'); 54 | 55 | $stream->expect(Token::BLOCK_END_TYPE); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Environment/StubbedEnvironment.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | private array $stubFilters = []; 35 | 36 | /** 37 | * @var array 38 | */ 39 | private array $stubFunctions = []; 40 | 41 | /** 42 | * @var array 43 | */ 44 | private array $stubTests = [ 45 | 'divisible' => null, // Allow 'divisible by' 46 | 'same' => null, // Allow 'same as' 47 | ]; 48 | 49 | /** 50 | * @param ExtensionInterface[] $customTwigExtensions 51 | * @param TokenParserInterface[] $customTokenParsers 52 | * @param NodeVisitorInterface[] $customNodeVisitors 53 | */ 54 | public function __construct( 55 | array $customTwigExtensions = [], 56 | array $customTokenParsers = [], 57 | array $customNodeVisitors = [], 58 | ) { 59 | parent::__construct(new ArrayLoader()); 60 | 61 | $this->handleOptionalDependencies(); 62 | 63 | foreach ($customTwigExtensions as $customTwigExtension) { 64 | $this->addExtension($customTwigExtension); 65 | } 66 | 67 | foreach ($customTokenParsers as $customTokenParser) { 68 | $this->addTokenParser($customTokenParser); 69 | } 70 | 71 | foreach ($customNodeVisitors as $customNodeVisitor) { 72 | $this->addNodeVisitor($customNodeVisitor); 73 | } 74 | } 75 | 76 | /** 77 | * Avoid dependency to composer/semver for twig version comparison. 78 | */ 79 | public static function satisfiesTwigVersion(int $major, int $minor = 0, int $patch = 0): bool 80 | { 81 | $version = explode('.', self::VERSION); 82 | 83 | if ($major < $version[0]) { 84 | return true; 85 | } 86 | if ($major > $version[0]) { 87 | return false; 88 | } 89 | if ($minor < $version[1]) { 90 | return true; 91 | } 92 | if ($minor > $version[1]) { 93 | return false; 94 | } 95 | 96 | return $version[2] >= $patch; 97 | } 98 | 99 | /** 100 | * @param string $name 101 | */ 102 | public function getFilter($name): ?TwigFilter 103 | { 104 | if (!\array_key_exists($name, $this->stubFilters)) { 105 | // @phpstan-ignore-next-line method.internal 106 | $existingFilter = parent::getFilter($name); 107 | $this->stubFilters[$name] = $existingFilter instanceof TwigFilter 108 | ? $existingFilter 109 | : new TwigFilter($name); 110 | } 111 | 112 | return $this->stubFilters[$name]; 113 | } 114 | 115 | /** 116 | * @param string $name 117 | */ 118 | public function getFunction($name): ?TwigFunction 119 | { 120 | if (!\array_key_exists($name, $this->stubFunctions)) { 121 | // @phpstan-ignore-next-line method.internal 122 | $existingFunction = parent::getFunction($name); 123 | $this->stubFunctions[$name] = $existingFunction instanceof TwigFunction 124 | ? $existingFunction 125 | : new TwigFunction($name); 126 | } 127 | 128 | return $this->stubFunctions[$name]; 129 | } 130 | 131 | /** 132 | * @param string $name 133 | */ 134 | public function getTest($name): ?TwigTest 135 | { 136 | if (!\array_key_exists($name, $this->stubTests)) { 137 | // @phpstan-ignore-next-line method.internal 138 | $existingTest = parent::getTest($name); 139 | $this->stubTests[$name] = $existingTest instanceof TwigTest 140 | ? $existingTest 141 | : new TwigTest($name); 142 | } 143 | 144 | return $this->stubTests[$name]; 145 | } 146 | 147 | private function handleOptionalDependencies(): void 148 | { 149 | if (class_exists(DumpTokenParser::class)) { 150 | $this->addTokenParser(new DumpTokenParser()); 151 | } 152 | if (class_exists(FormThemeTokenParser::class)) { 153 | $this->addTokenParser(new FormThemeTokenParser()); 154 | } 155 | if (class_exists(StopwatchTokenParser::class)) { 156 | $this->addTokenParser(new StopwatchTokenParser(true)); 157 | } 158 | if (class_exists(TransDefaultDomainTokenParser::class)) { 159 | $this->addTokenParser(new TransDefaultDomainTokenParser()); 160 | } 161 | if (class_exists(TransTokenParser::class)) { 162 | $this->addTokenParser(new TransTokenParser()); 163 | } 164 | if (class_exists(CacheTokenParser::class)) { 165 | $this->addTokenParser(new CacheTokenParser()); 166 | } 167 | // @phpstan-ignore-next-line classConstant.internalClass 168 | if (class_exists(TwigComponentTokenParser::class)) { 169 | $this->addTokenParser(new ComponentTokenParser()); 170 | } 171 | // @phpstan-ignore-next-line classConstant.internalClass 172 | if (class_exists(PropsTokenParser::class)) { 173 | // @phpstan-ignore-next-line new.internalClass 174 | $this->addTokenParser(new PropsTokenParser()); 175 | } 176 | // @phpstan-ignore-next-line classConstant.internalClass 177 | if (class_exists(ComponentLexer::class)) { 178 | // @phpstan-ignore-next-line new.internalClass 179 | $this->setLexer(new ComponentLexer($this)); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Exception/CannotFixFileException.php: -------------------------------------------------------------------------------- 1 | getMessage()), 18 | $exception->getCode(), 19 | $exception 20 | ); 21 | } 22 | 23 | public static function locationIsDirectory(string $path): self 24 | { 25 | return new self(\sprintf('Cannot write cache file "%s" as the location exists as directory.', $path)); 26 | } 27 | 28 | public static function locationIsNotWritable(string $path): self 29 | { 30 | return new self(\sprintf('Cannot write to file "%s" as it is not writable.', $path)); 31 | } 32 | 33 | public static function missingDirectory(string $path): self 34 | { 35 | return new self(\sprintf('Directory of cache file "%s" does not exists.', $path)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/File/FileHelper.php: -------------------------------------------------------------------------------- 1 | $matches */ 19 | 20 | return $matches[0] ?? \PHP_EOL; 21 | } 22 | 23 | public static function normalizePath(string $path, string $separator = \DIRECTORY_SEPARATOR): string 24 | { 25 | return str_replace(['\\', '/'], $separator, $path); 26 | } 27 | 28 | public static function getRelativePath(string $path, string $baseDir): string 29 | { 30 | $path = static::normalizePath($path); 31 | 32 | if ( 33 | '' === $baseDir 34 | || 0 !== stripos($path, $baseDir.\DIRECTORY_SEPARATOR) 35 | ) { 36 | return $path; 37 | } 38 | 39 | return substr($path, \strlen($baseDir) + 1); 40 | } 41 | 42 | public static function getAbsolutePath(string $path, ?string $workingDir = null): string 43 | { 44 | if (Path::isAbsolute($path)) { 45 | return $path; 46 | } 47 | 48 | $workingDir ??= @getcwd(); 49 | Assert::notFalse($workingDir, 'Cannot get the current working directory.'); 50 | 51 | return $workingDir.\DIRECTORY_SEPARATOR.$path; 52 | } 53 | 54 | public static function removeDot(string $fileName): string 55 | { 56 | if (!str_starts_with($fileName, '.')) { 57 | return $fileName; 58 | } 59 | 60 | return substr($fileName, 1); 61 | } 62 | 63 | /** 64 | * @param array $ignoredDir 65 | */ 66 | public static function getFileName( 67 | string $path, 68 | ?string $baseDir = null, 69 | array $ignoredDir = [], 70 | ?string $workingDir = null, 71 | ): ?string { 72 | $split = self::splitPath($path, $baseDir, $ignoredDir, $workingDir); 73 | if ([] === $split) { 74 | return null; 75 | } 76 | 77 | return end($split); 78 | } 79 | 80 | /** 81 | * @param array $ignoredDir 82 | * 83 | * @return list 84 | */ 85 | public static function getDirectories( 86 | string $path, 87 | ?string $baseDir = null, 88 | array $ignoredDir = [], 89 | ?string $workingDir = null, 90 | ): array { 91 | $split = self::splitPath($path, $baseDir, $ignoredDir, $workingDir); 92 | array_pop($split); 93 | 94 | return $split; 95 | } 96 | 97 | /** 98 | * @param array $ignoredDir 99 | * 100 | * @return list 101 | */ 102 | private static function splitPath( 103 | string $path, 104 | ?string $baseDir = null, 105 | array $ignoredDir = [], 106 | ?string $workingDir = null, 107 | ): array { 108 | $baseDir = Path::canonicalize(self::getAbsolutePath($baseDir ?? '', $workingDir)); 109 | $path = Path::canonicalize(self::getAbsolutePath($path, $workingDir)); 110 | 111 | if (!str_starts_with($path, $baseDir.'/')) { 112 | return []; 113 | } 114 | 115 | foreach ($ignoredDir as $ignoredDirectory) { 116 | $ignoredDirectory = Path::canonicalize(self::getAbsolutePath($ignoredDirectory, $baseDir)); 117 | if (str_starts_with($path, $ignoredDirectory.'/')) { 118 | return []; 119 | } 120 | } 121 | 122 | return explode('/', substr($path, \strlen($baseDir) + 1)); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/File/Finder.php: -------------------------------------------------------------------------------- 1 | files() 17 | ->name('/\.twig$/') 18 | ->exclude('node_modules') 19 | ->exclude('vendor'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Report/Report.php: -------------------------------------------------------------------------------- 1 | > 19 | */ 20 | private array $violationsByFile = []; 21 | 22 | /** 23 | * @var array 24 | */ 25 | private array $fixedFiles = []; 26 | 27 | private int $totalNotices = 0; 28 | 29 | private int $totalWarnings = 0; 30 | 31 | private int $totalErrors = 0; 32 | 33 | /** 34 | * @var array 35 | */ 36 | private array $realPaths = []; 37 | 38 | /** 39 | * @param iterable<\SplFileInfo> $files 40 | */ 41 | public function __construct(iterable $files) 42 | { 43 | foreach ($files as $file) { 44 | $pathName = $file->getPathname(); 45 | $realPath = $file->getRealPath(); 46 | 47 | $this->realPaths[$pathName] = false !== $realPath ? $realPath : $pathName; 48 | $this->violationsByFile[$pathName] = []; 49 | } 50 | } 51 | 52 | public function addViolation(Violation $violation): self 53 | { 54 | $filename = $violation->getFilename(); 55 | if (!isset($this->violationsByFile[$filename])) { 56 | throw new \InvalidArgumentException( 57 | \sprintf('The file "%s" is not handled by this report.', $filename) 58 | ); 59 | } 60 | 61 | // Update stats 62 | switch ($violation->getLevel()) { 63 | case Violation::LEVEL_NOTICE: 64 | $this->totalNotices++; 65 | break; 66 | case Violation::LEVEL_WARNING: 67 | $this->totalWarnings++; 68 | break; 69 | case Violation::LEVEL_ERROR: 70 | case Violation::LEVEL_FATAL: 71 | $this->totalErrors++; 72 | break; 73 | } 74 | 75 | $this->violationsByFile[$filename][] = $violation; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * @return list 82 | */ 83 | public function getFileViolations(string $filename, ?string $level = null): array 84 | { 85 | if (!isset($this->violationsByFile[$filename])) { 86 | throw new \InvalidArgumentException( 87 | \sprintf('The file "%s" is not handled by this report.', $filename) 88 | ); 89 | } 90 | 91 | if (null === $level) { 92 | return $this->violationsByFile[$filename]; 93 | } 94 | 95 | return array_values( 96 | array_filter( 97 | $this->violationsByFile[$filename], 98 | static fn (Violation $message): bool => $message->getLevel() >= Violation::getLevelAsInt($level) 99 | ) 100 | ); 101 | } 102 | 103 | /** 104 | * @return list 105 | */ 106 | public function getViolations(?string $level = null): array 107 | { 108 | $messages = array_merge(...array_values($this->violationsByFile)); 109 | 110 | if (null === $level) { 111 | return $messages; 112 | } 113 | 114 | return array_values( 115 | array_filter( 116 | $messages, 117 | static fn (Violation $message): bool => $message->getLevel() >= Violation::getLevelAsInt($level) 118 | ) 119 | ); 120 | } 121 | 122 | /** 123 | * @return list 124 | */ 125 | public function getFiles(): array 126 | { 127 | return array_keys($this->violationsByFile); 128 | } 129 | 130 | public function getRealPath(string $filename): string 131 | { 132 | if (!isset($this->realPaths[$filename])) { 133 | throw new \InvalidArgumentException( 134 | \sprintf('The file "%s" is not handled by this report.', $filename) 135 | ); 136 | } 137 | 138 | return $this->realPaths[$filename]; 139 | } 140 | 141 | public function getTotalFiles(): int 142 | { 143 | return \count($this->violationsByFile); 144 | } 145 | 146 | public function addFixedFile(string $filename): self 147 | { 148 | if (!isset($this->violationsByFile[$filename])) { 149 | throw new \InvalidArgumentException( 150 | \sprintf('The file "%s" is not handled by this report.', $filename) 151 | ); 152 | } 153 | 154 | $this->fixedFiles[$filename] = true; 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * @return list 161 | */ 162 | public function getFixedFiles(): array 163 | { 164 | return array_keys($this->fixedFiles); 165 | } 166 | 167 | public function getTotalNotices(): int 168 | { 169 | return $this->totalNotices; 170 | } 171 | 172 | public function getTotalWarnings(): int 173 | { 174 | return $this->totalWarnings; 175 | } 176 | 177 | public function getTotalErrors(): int 178 | { 179 | return $this->totalErrors; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Report/Reporter/CheckstyleReporter.php: -------------------------------------------------------------------------------- 1 | '.\PHP_EOL; 27 | 28 | $text .= ''.\PHP_EOL; 29 | 30 | foreach ($report->getFiles() as $file) { 31 | $fileViolations = $report->getFileViolations($file, $level); 32 | if (0 === \count($fileViolations)) { 33 | continue; 34 | } 35 | 36 | $realPath = $report->getRealPath($file); 37 | $text .= \sprintf(' ', $this->xmlEncode($realPath)).\PHP_EOL; 38 | foreach ($fileViolations as $violation) { 39 | $line = (string) $violation->getLine(); 40 | $linePosition = (string) $violation->getLinePosition(); 41 | $ruleName = $violation->getRuleName(); 42 | 43 | $text .= ' getLevel())).'"'; 51 | $text .= ' message="'.$this->xmlEncode($violation->getDebugMessage($debug)).'"'; 52 | if (null !== $ruleName) { 53 | $text .= ' source="'.$ruleName.'"'; 54 | } 55 | $text .= '/>'.\PHP_EOL; 56 | } 57 | $text .= ' '.\PHP_EOL; 58 | } 59 | 60 | $text .= ''; 61 | 62 | $output->writeln($text); 63 | } 64 | 65 | private function xmlEncode(string $data): string 66 | { 67 | return htmlspecialchars($data, \ENT_XML1 | \ENT_QUOTES); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Report/Reporter/GithubReporter.php: -------------------------------------------------------------------------------- 1 | getViolations($level); 32 | foreach ($violations as $violation) { 33 | $text = match ($violation->getLevel()) { 34 | Violation::LEVEL_NOTICE => '::notice', 35 | Violation::LEVEL_WARNING => '::warning', 36 | default => '::error', 37 | }; 38 | 39 | $text .= ' file='.$violation->getFilename(); 40 | 41 | $line = (string) $violation->getLine(); 42 | if ('' !== $line) { 43 | $text .= ',line='.$line; 44 | } 45 | $linePosition = (string) $violation->getLinePosition(); 46 | if ('' !== $linePosition) { 47 | $text .= ',col='.$linePosition; 48 | } 49 | 50 | // newlines need to be encoded 51 | // see https://github.com/actions/starter-workflows/issues/68#issuecomment-581479448 52 | $text .= '::'.str_replace("\n", '%0A', $violation->getDebugMessage($debug)); 53 | 54 | $output->writeln($text); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Report/Reporter/GitlabReporter.php: -------------------------------------------------------------------------------- 1 | */ 22 | private array $hashes = []; 23 | 24 | public function getName(): string 25 | { 26 | return self::NAME; 27 | } 28 | 29 | /** 30 | * @throws \JsonException 31 | */ 32 | public function display( 33 | OutputInterface $output, 34 | Report $report, 35 | ?string $level, 36 | bool $debug, 37 | ): void { 38 | $reports = []; 39 | 40 | foreach ($report->getViolations() as $violation) { 41 | $filename = $violation->getFilename(); 42 | $severity = match ($violation->getLevel()) { 43 | Violation::LEVEL_WARNING => 'minor', 44 | Violation::LEVEL_ERROR => 'major', 45 | Violation::LEVEL_FATAL => 'critical', 46 | default => 'info', 47 | }; 48 | 49 | $reports[] = [ 50 | 'description' => $violation->getDebugMessage($debug), 51 | 'check_name' => $violation->getRuleName() ?? '', 52 | 'fingerprint' => $this->generateFingerprint($filename, $violation), 53 | 'severity' => $severity, 54 | 'location' => [ 55 | 'path' => $filename, 56 | 'lines' => [ 57 | 'begin' => $violation->getLine() ?? 1, 58 | ], 59 | ], 60 | ]; 61 | } 62 | 63 | $json = json_encode($reports, \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR); 64 | 65 | $output->writeln($json); 66 | } 67 | 68 | /** 69 | * Generate a unique fingerprint to identify this specific code quality violation, such as a hash of its contents. 70 | * 71 | * We do not use the ViolationId to generate the fingerprint because : 72 | * - The ViolationId::toString returns the line and linePosition of the violation. 73 | * - Using code location when creating hash for Gitlab fingerprints makes the code-quality reports in Gitlab very unstable. 74 | * - Any change of position would trigger both a "fixed" message, and a "new problem detected" message in Gitlab, making it very noisy. 75 | * 76 | * @see https://github.com/astral-sh/ruff/pull/7203 77 | */ 78 | private function generateFingerprint(string $relativePath, Violation $violation): string 79 | { 80 | // Use the same separator cross-platform to generate the same fingerprint. 81 | $normalizedPath = FileHelper::normalizePath($relativePath, '/'); 82 | $base = $normalizedPath.$violation->getRuleName().$violation->getMessage(); 83 | 84 | $hash = md5($base); 85 | 86 | // Check if the generated hash does not already exist 87 | // Keep generating new hashes until we get a unique one 88 | while (\in_array($hash, $this->hashes, true)) { 89 | $hash = md5($hash); 90 | } 91 | 92 | $this->hashes[] = $hash; 93 | 94 | return $hash; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Report/Reporter/JUnitReporter.php: -------------------------------------------------------------------------------- 1 | getViolations($level); 27 | $count = \count($violations); 28 | 29 | $text = ''.\PHP_EOL; 30 | $text .= ''.\PHP_EOL; 31 | $text .= ' '.\sprintf( 32 | '', 33 | max($count, 1), 34 | $count 35 | ).\PHP_EOL; 36 | 37 | if ($count > 0) { 38 | foreach ($violations as $violation) { 39 | $text .= $this->createTestCase( 40 | \sprintf('%s:%s', $report->getRealPath($violation->getFilename()), $violation->getLine() ?? 0), 41 | strtolower(Violation::getLevelAsString($violation->getLevel())), 42 | $violation->getDebugMessage($debug) 43 | ); 44 | } 45 | } else { 46 | $text .= $this->createTestCase('All OK'); 47 | } 48 | 49 | $text .= ' '.\PHP_EOL; 50 | $text .= ''; 51 | 52 | $output->writeln($text); 53 | } 54 | 55 | private function createTestCase(string $name, string $type = '', ?string $message = null): string 56 | { 57 | $result = ' '.\sprintf('', $this->xmlEncode($name)).\PHP_EOL; 58 | 59 | if (null !== $message) { 60 | $result .= ' ' 61 | .\sprintf('', $this->xmlEncode($type), $this->xmlEncode($message)) 62 | .\PHP_EOL; 63 | } 64 | 65 | $result .= ' '.\PHP_EOL; 66 | 67 | return $result; 68 | } 69 | 70 | private function xmlEncode(string $data): string 71 | { 72 | return htmlspecialchars($data, \ENT_XML1 | \ENT_QUOTES); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Report/Reporter/NullReporter.php: -------------------------------------------------------------------------------- 1 | >'; 23 | private const ERROR_LINE_FORMAT = '%-5s| %s'; 24 | private const ERROR_LINE_WIDTH = 120; 25 | 26 | public function getName(): string 27 | { 28 | return self::NAME; 29 | } 30 | 31 | public function display( 32 | OutputInterface $output, 33 | Report $report, 34 | ?string $level, 35 | bool $debug, 36 | ): void { 37 | $io = new SymfonyStyle(new ArrayInput([]), $output); 38 | 39 | if ( 40 | $io->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE 41 | && [] !== $report->getFixedFiles() 42 | ) { 43 | $io->text('Changed:'); 44 | $io->listing($report->getFixedFiles()); 45 | } 46 | 47 | foreach ($report->getFiles() as $file) { 48 | $fileViolations = $report->getFileViolations($file, $level); 49 | if (\count($fileViolations) > 0) { 50 | $io->text(\sprintf('KO %s', $file)); 51 | } 52 | 53 | $content = @file_get_contents($file); 54 | $lines = false !== $content ? preg_split("/\r\n?|\n/", $content) : false; 55 | 56 | $rows = []; 57 | foreach ($fileViolations as $violation) { 58 | $formattedText = []; 59 | $line = $violation->getLine(); 60 | 61 | if (null === $line || false === $lines) { 62 | $formattedText[] = $this->formatErrorMessage($violation, $debug); 63 | } else { 64 | $context = $this->getContext($lines, $line); 65 | foreach ($context as $no => $code) { 66 | $formattedText[] = \sprintf( 67 | self::ERROR_LINE_FORMAT, 68 | $no, 69 | wordwrap($code, self::ERROR_LINE_WIDTH, \PHP_EOL) 70 | ); 71 | 72 | if ($no === $violation->getLine()) { 73 | $formattedText[] = $this->formatErrorMessage($violation, $debug); 74 | } 75 | } 76 | } 77 | 78 | if (\count($rows) > 0) { 79 | $rows[] = new TableSeparator(); 80 | } 81 | 82 | $messageLevel = Violation::getLevelAsString($violation->getLevel()); 83 | $rows[] = [ 84 | new TableCell(\sprintf('%s', $messageLevel)), 85 | implode(\PHP_EOL, $formattedText), 86 | ]; 87 | } 88 | 89 | if (\count($rows) > 0) { 90 | $io->table([], $rows); 91 | } 92 | } 93 | 94 | $summaryString = \sprintf( 95 | 'Files linted: %d, notices: %d, warnings: %d, errors: %d', 96 | $report->getTotalFiles(), 97 | $report->getTotalNotices(), 98 | $report->getTotalWarnings(), 99 | $report->getTotalErrors() 100 | ); 101 | 102 | if (0 < $report->getTotalErrors()) { 103 | $io->error($summaryString); 104 | } elseif (0 < $report->getTotalWarnings()) { 105 | $io->warning($summaryString); 106 | } else { 107 | $io->success($summaryString); 108 | } 109 | } 110 | 111 | /** 112 | * @param list $templatesLines 113 | * 114 | * @return array 115 | */ 116 | private function getContext(array $templatesLines, int $line): array 117 | { 118 | $lineIndex = max(0, $line - 2); 119 | $max = min(\count($templatesLines), $line + 1); 120 | 121 | $result = []; 122 | $indents = []; 123 | 124 | do { 125 | preg_match('/^[\s\t]+/', $templatesLines[$lineIndex], $match); 126 | $indents[] = \strlen($match[0] ?? ''); 127 | $result[$lineIndex + 1] = $templatesLines[$lineIndex]; 128 | ++$lineIndex; 129 | } while ($lineIndex < $max); 130 | 131 | return array_map(static fn (string $code): string => substr($code, min($indents)), $result); 132 | } 133 | 134 | private function formatErrorMessage(Violation $message, bool $debug): string 135 | { 136 | return \sprintf( 137 | \sprintf('%s', self::ERROR_LINE_FORMAT), 138 | self::ERROR_CURSOR_CHAR, 139 | wordwrap($message->getDebugMessage($debug), self::ERROR_LINE_WIDTH, \PHP_EOL) 140 | ); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Report/ReporterFactory.php: -------------------------------------------------------------------------------- 1 | $customReporters 19 | */ 20 | public function __construct( 21 | private array $customReporters = [], 22 | ) { 23 | } 24 | 25 | public function getReporter(string $format = TextReporter::NAME): ReporterInterface 26 | { 27 | foreach ($this->customReporters as $reporter) { 28 | if ($format === $reporter->getName()) { 29 | return $reporter; 30 | } 31 | } 32 | 33 | return match ($format) { 34 | NullReporter::NAME => new NullReporter(), 35 | TextReporter::NAME => new TextReporter(), 36 | CheckstyleReporter::NAME => new CheckstyleReporter(), 37 | JUnitReporter::NAME => new JUnitReporter(), 38 | GithubReporter::NAME => new GithubReporter(), 39 | GitlabReporter::NAME => new GitlabReporter(), 40 | default => throw new \InvalidArgumentException( 41 | \sprintf('No reporter supports the format "%s".', $format) 42 | ), 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Report/Violation.php: -------------------------------------------------------------------------------- 1 | level; 29 | } 30 | 31 | public static function getLevelAsString(int $level): string 32 | { 33 | return match ($level) { 34 | self::LEVEL_NOTICE => Report::MESSAGE_TYPE_NOTICE, 35 | self::LEVEL_WARNING => Report::MESSAGE_TYPE_WARNING, 36 | self::LEVEL_ERROR => Report::MESSAGE_TYPE_ERROR, 37 | self::LEVEL_FATAL => Report::MESSAGE_TYPE_FATAL, 38 | default => throw new \InvalidArgumentException( 39 | \sprintf('Level "%s" is not supported.', $level) 40 | ), 41 | }; 42 | } 43 | 44 | public static function getLevelAsInt(string $level): int 45 | { 46 | return match (strtoupper($level)) { 47 | Report::MESSAGE_TYPE_NOTICE => self::LEVEL_NOTICE, 48 | Report::MESSAGE_TYPE_WARNING => self::LEVEL_WARNING, 49 | Report::MESSAGE_TYPE_ERROR => self::LEVEL_ERROR, 50 | Report::MESSAGE_TYPE_FATAL => self::LEVEL_FATAL, 51 | default => throw new \InvalidArgumentException( 52 | \sprintf('Level "%s" is not supported.', $level) 53 | ), 54 | }; 55 | } 56 | 57 | public function getMessage(): string 58 | { 59 | return $this->message; 60 | } 61 | 62 | public function getDebugMessage(bool $debug = true): string 63 | { 64 | $prefix = ''; 65 | if ($debug) { 66 | $identifier = $this->getIdentifier()?->toString(); 67 | if (null !== $identifier) { 68 | $prefix = $identifier.' -- '; 69 | } 70 | } 71 | 72 | return $prefix.$this->message; 73 | } 74 | 75 | public function getFilename(): string 76 | { 77 | return $this->filename; 78 | } 79 | 80 | public function getRuleName(): ?string 81 | { 82 | return $this->ruleName; 83 | } 84 | 85 | public function getIdentifier(): ?ViolationId 86 | { 87 | return $this->identifier; 88 | } 89 | 90 | public function getLine(): ?int 91 | { 92 | return $this->identifier?->getLine(); 93 | } 94 | 95 | public function getLinePosition(): ?int 96 | { 97 | return $this->identifier?->getLinePosition(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Report/ViolationId.php: -------------------------------------------------------------------------------- 1 | line; 20 | } 21 | 22 | public function getLinePosition(): ?int 23 | { 24 | return $this->linePosition; 25 | } 26 | 27 | public static function fromString(string $string, ?int $line = null): self 28 | { 29 | $exploded = explode(':', $string); 30 | $name = $exploded[0]; 31 | $explodedName = '' !== $name ? explode('.', $name) : null; 32 | 33 | $line ??= isset($exploded[1]) && '' !== $exploded[1] ? (int) $exploded[1] : null; 34 | $linePosition = isset($exploded[2]) && '' !== $exploded[2] ? (int) $exploded[2] : null; 35 | 36 | return new self( 37 | $explodedName[0] ?? null, 38 | $explodedName[1] ?? null, 39 | $line, 40 | $linePosition 41 | ); 42 | } 43 | 44 | public function toString(): string 45 | { 46 | $name = rtrim(\sprintf( 47 | '%s.%s', 48 | $this->ruleIdentifier ?? '', 49 | $this->messageIdentifier ?? '', 50 | ), '.'); 51 | 52 | return rtrim(\sprintf( 53 | '%s:%s:%s', 54 | $name, 55 | $this->line ?? '', 56 | $this->linePosition ?? '', 57 | ), ':'); 58 | } 59 | 60 | public function match(self $violationId): bool 61 | { 62 | return $this->matchValue($this->ruleIdentifier, $violationId->ruleIdentifier) 63 | && $this->matchValue($this->messageIdentifier, $violationId->messageIdentifier) 64 | && $this->matchValue($this->line, $violationId->line) 65 | && $this->matchValue($this->linePosition, $violationId->linePosition); 66 | } 67 | 68 | private function matchValue(string|int|null $self, string|int|null $other): bool 69 | { 70 | return null === $self || strtolower((string) $self) === strtolower((string) $other); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Rules/AbstractFixableRule.php: -------------------------------------------------------------------------------- 1 | fixer = $fixer; 23 | } 24 | 25 | final public function fixFile(Tokens $tokens, FixerInterface $fixer): void 26 | { 27 | $this->init(null, $tokens->getIgnoredViolations(), $fixer); 28 | 29 | foreach (array_keys($tokens->toArray()) as $index) { 30 | $this->process($index, $tokens); 31 | } 32 | } 33 | 34 | final protected function addFixableWarning( 35 | string $message, 36 | Token $token, 37 | ?string $messageId = null, 38 | ): ?FixerInterface { 39 | $added = $this->addWarning($message, $token, $messageId); 40 | if (!$added) { 41 | return null; 42 | } 43 | 44 | return $this->fixer; 45 | } 46 | 47 | final protected function addFixableError( 48 | string $message, 49 | Token $token, 50 | ?string $messageId = null, 51 | ): ?FixerInterface { 52 | $added = $this->addError($message, $token, $messageId); 53 | if (!$added) { 54 | return null; 55 | } 56 | 57 | return $this->fixer; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Rules/AbstractRule.php: -------------------------------------------------------------------------------- 1 | init($report, $tokens->getIgnoredViolations()); 20 | 21 | foreach (array_keys($tokens->toArray()) as $index) { 22 | $this->process($index, $tokens); 23 | } 24 | } 25 | 26 | /** 27 | * @param list $ignoredViolations 28 | */ 29 | protected function init(?Report $report, array $ignoredViolations = []): void 30 | { 31 | $this->report = $report; 32 | $this->ignoredViolations = $ignoredViolations; 33 | } 34 | 35 | abstract protected function process(int $tokenIndex, Tokens $tokens): void; 36 | 37 | final protected function addWarning(string $message, Token $token, ?string $messageId = null): bool 38 | { 39 | return $this->addMessage( 40 | Violation::LEVEL_WARNING, 41 | $message, 42 | $token->getFilename(), 43 | $token->getLine(), 44 | $token->getLinePosition(), 45 | $messageId, 46 | ); 47 | } 48 | 49 | final protected function addFileWarning(string $message, Token $token, ?string $messageId = null): bool 50 | { 51 | return $this->addMessage( 52 | Violation::LEVEL_WARNING, 53 | $message, 54 | $token->getFilename(), 55 | null, 56 | null, 57 | $messageId, 58 | ); 59 | } 60 | 61 | final protected function addError(string $message, Token $token, ?string $messageId = null): bool 62 | { 63 | return $this->addMessage( 64 | Violation::LEVEL_ERROR, 65 | $message, 66 | $token->getFilename(), 67 | $token->getLine(), 68 | $token->getLinePosition(), 69 | $messageId, 70 | ); 71 | } 72 | 73 | final protected function addFileError(string $message, Token $token, ?string $messageId = null): bool 74 | { 75 | return $this->addMessage( 76 | Violation::LEVEL_ERROR, 77 | $message, 78 | $token->getFilename(), 79 | null, 80 | null, 81 | $messageId, 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Rules/AbstractSpacingRule.php: -------------------------------------------------------------------------------- 1 | getSpaceAfter($tokenIndex, $tokens); 20 | $spaceBefore = $this->getSpaceBefore($tokenIndex, $tokens); 21 | 22 | if (null !== $spaceAfter) { 23 | $this->checkSpaceAfter($tokenIndex, $tokens, $spaceAfter); 24 | } 25 | 26 | if (null !== $spaceBefore) { 27 | $this->checkSpaceBefore($tokenIndex, $tokens, $spaceBefore); 28 | } 29 | } 30 | 31 | abstract protected function getSpaceAfter(int $tokenIndex, Tokens $tokens): ?int; 32 | 33 | abstract protected function getSpaceBefore(int $tokenIndex, Tokens $tokens): ?int; 34 | 35 | private function checkSpaceAfter(int $tokenIndex, Tokens $tokens, int $expected): void 36 | { 37 | $token = $tokens->get($tokenIndex); 38 | $next = $tokens->findNext(Token::INDENT_TOKENS, $tokenIndex + 1, exclude: true); 39 | if (false === $next) { 40 | return; 41 | } 42 | 43 | if ($tokens->get($next)->isMatching(Token::EOL_TOKENS)) { 44 | if ($this->skipIfNewLine) { 45 | return; 46 | } 47 | 48 | $found = 'newline'; 49 | } elseif ($tokens->get($tokenIndex + 1)->isMatching(Token::WHITESPACE_TOKENS)) { 50 | $found = \strlen($tokens->get($tokenIndex + 1)->getValue()); 51 | } else { 52 | $found = 0; 53 | } 54 | 55 | if ($expected === $found) { 56 | return; 57 | } 58 | 59 | $fixer = $this->addFixableError( 60 | \sprintf('Expecting %d whitespace after "%s"; found %s.', $expected, $token->getValue(), $found), 61 | $token, 62 | 'After' 63 | ); 64 | 65 | if (null === $fixer) { 66 | return; 67 | } 68 | 69 | $index = $tokenIndex + 1; 70 | $tokensToReplace = $this->skipIfNewLine 71 | ? Token::INDENT_TOKENS 72 | : Token::INDENT_TOKENS + Token::EOL_TOKENS; 73 | 74 | $fixer->beginChangeSet(); 75 | while ( 76 | $tokens->has($index) 77 | && $tokens->get($index)->isMatching($tokensToReplace) 78 | ) { 79 | $fixer->replaceToken($index, ''); 80 | ++$index; 81 | } 82 | $fixer->addContent($tokenIndex, str_repeat(' ', $expected)); 83 | $fixer->endChangeSet(); 84 | } 85 | 86 | private function checkSpaceBefore(int $tokenIndex, Tokens $tokens, int $expected): void 87 | { 88 | $token = $tokens->get($tokenIndex); 89 | 90 | $previous = $tokens->findPrevious(Token::INDENT_TOKENS, $tokenIndex - 1, exclude: true); 91 | if (false === $previous) { 92 | return; 93 | } 94 | 95 | if ($tokens->get($previous)->isMatching(Token::EOL_TOKENS)) { 96 | if ($this->skipIfNewLine) { 97 | return; 98 | } 99 | 100 | $found = 'newline'; 101 | } elseif ($tokens->get($tokenIndex - 1)->isMatching(Token::WHITESPACE_TOKENS)) { 102 | $found = \strlen($tokens->get($tokenIndex - 1)->getValue()); 103 | } else { 104 | $found = 0; 105 | } 106 | 107 | if ($expected === $found) { 108 | return; 109 | } 110 | 111 | $fixer = $this->addFixableError( 112 | \sprintf('Expecting %d whitespace before "%s"; found %s.', $expected, $token->getValue(), $found), 113 | $token, 114 | 'Before' 115 | ); 116 | 117 | if (null === $fixer) { 118 | return; 119 | } 120 | 121 | $index = $tokenIndex - 1; 122 | $tokensToReplace = $this->skipIfNewLine 123 | ? Token::INDENT_TOKENS 124 | : Token::INDENT_TOKENS + Token::EOL_TOKENS; 125 | 126 | $fixer->beginChangeSet(); 127 | while ( 128 | $tokens->has($index) 129 | && $tokens->get($index)->isMatching($tokensToReplace) 130 | ) { 131 | $fixer->replaceToken($index, ''); 132 | --$index; 133 | } 134 | $fixer->addContentBefore($tokenIndex, str_repeat(' ', $expected)); 135 | $fixer->endChangeSet(); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Rules/ConfigurableRuleInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function getConfiguration(): array; 13 | } 14 | -------------------------------------------------------------------------------- /src/Rules/Delimiter/BlockNameSpacingRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 19 | if ($token->isMatching(Token::BLOCK_NAME_TYPE)) { 20 | return 1; 21 | } 22 | 23 | return null; 24 | } 25 | 26 | protected function getSpaceAfter(int $tokenIndex, Tokens $tokens): ?int 27 | { 28 | $token = $tokens->get($tokenIndex); 29 | if ($token->isMatching(Token::BLOCK_NAME_TYPE)) { 30 | return 1; 31 | } 32 | 33 | return null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Rules/Delimiter/DelimiterSpacingRule.php: -------------------------------------------------------------------------------- 1 | skipIfNewLine = $skipIfNewLine; 20 | } 21 | 22 | public function getConfiguration(): array 23 | { 24 | return [ 25 | 'skipIfNewLine' => $this->skipIfNewLine, 26 | ]; 27 | } 28 | 29 | protected function getSpaceBefore(int $tokenIndex, Tokens $tokens): ?int 30 | { 31 | $token = $tokens->get($tokenIndex); 32 | if ( 33 | $token->isMatching([ 34 | Token::BLOCK_END_TYPE, 35 | Token::COMMENT_END_TYPE, 36 | Token::VAR_END_TYPE, 37 | ]) 38 | ) { 39 | return 1; 40 | } 41 | 42 | return null; 43 | } 44 | 45 | protected function getSpaceAfter(int $tokenIndex, Tokens $tokens): ?int 46 | { 47 | $token = $tokens->get($tokenIndex); 48 | if ( 49 | $token->isMatching([ 50 | Token::BLOCK_START_TYPE, 51 | Token::COMMENT_START_TYPE, 52 | Token::VAR_START_TYPE, 53 | ]) 54 | ) { 55 | return 1; 56 | } 57 | 58 | return null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Rules/File/DirectoryNameRule.php: -------------------------------------------------------------------------------- 1 | $ignoredSubDirectories 26 | */ 27 | public function __construct( 28 | private string $case = self::SNAKE_CASE, 29 | private ?string $baseDirectory = null, 30 | private array $ignoredSubDirectories = [], 31 | private string $optionalPrefix = '', 32 | ) { 33 | } 34 | 35 | public function getConfiguration(): array 36 | { 37 | return [ 38 | 'case' => $this->case, 39 | 'baseDirectory' => $this->baseDirectory, 40 | 'ignoredSubDirectories' => $this->ignoredSubDirectories, 41 | 'optionalPrefix' => $this->optionalPrefix, 42 | ]; 43 | } 44 | 45 | protected function process(int $tokenIndex, Tokens $tokens): void 46 | { 47 | if (0 !== $tokenIndex) { 48 | return; 49 | } 50 | 51 | $token = $tokens->get($tokenIndex); 52 | $directories = FileHelper::getDirectories( 53 | $token->getFilename(), 54 | $this->baseDirectory, 55 | $this->ignoredSubDirectories, 56 | ); 57 | 58 | foreach ($directories as $directory) { 59 | $prefix = ''; 60 | if (str_starts_with($directory, $this->optionalPrefix)) { 61 | $prefix = $this->optionalPrefix; 62 | $directory = substr($directory, \strlen($this->optionalPrefix)); 63 | } 64 | 65 | $expected = match ($this->case) { 66 | self::SNAKE_CASE => StringUtil::toSnakeCase($directory), 67 | self::CAMEL_CASE => StringUtil::toCamelCase($directory), 68 | self::PASCAL_CASE => StringUtil::toPascalCase($directory), 69 | self::KEBAB_CASE => StringUtil::toKebabCase($directory), 70 | }; 71 | 72 | if ($expected !== $directory) { 73 | $this->addFileError( 74 | \sprintf('The directory name must use %s; expected %s.', $this->case, $prefix.$expected), 75 | $token, 76 | ); 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Rules/File/FileExtensionRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 23 | $fileName = FileHelper::getFileName($token->getFilename()) ?? ''; 24 | $fileParts = explode('.', FileHelper::removeDot($fileName)); 25 | 26 | $fileExtension = array_pop($fileParts); 27 | if ('twig' !== $fileExtension) { 28 | return; 29 | } 30 | 31 | if (\count($fileParts) < 2) { 32 | $this->addFileError( 33 | \sprintf('The file must use two extensions; found ".%s".', $fileExtension), 34 | $token, 35 | ); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Rules/File/FileNameRule.php: -------------------------------------------------------------------------------- 1 | $ignoredSubDirectories 26 | */ 27 | public function __construct( 28 | private string $case = self::SNAKE_CASE, 29 | private ?string $baseDirectory = null, 30 | private array $ignoredSubDirectories = [], 31 | private string $optionalPrefix = '', 32 | ) { 33 | } 34 | 35 | public function getConfiguration(): array 36 | { 37 | return [ 38 | 'case' => $this->case, 39 | 'baseDirectory' => $this->baseDirectory, 40 | 'ignoredSubDirectories' => $this->ignoredSubDirectories, 41 | 'optionalPrefix' => $this->optionalPrefix, 42 | ]; 43 | } 44 | 45 | protected function process(int $tokenIndex, Tokens $tokens): void 46 | { 47 | if (0 !== $tokenIndex) { 48 | return; 49 | } 50 | 51 | $token = $tokens->get($tokenIndex); 52 | $fileName = FileHelper::getFileName( 53 | $token->getFilename(), 54 | $this->baseDirectory, 55 | $this->ignoredSubDirectories, 56 | ); 57 | if (null === $fileName) { 58 | return; 59 | } 60 | 61 | // We're only checking the first part before a dot, 62 | // in order to avoid conflict with some file extensions. 63 | $fileName = explode('.', FileHelper::removeDot($fileName))[0]; 64 | 65 | $prefix = ''; 66 | if (str_starts_with($fileName, $this->optionalPrefix)) { 67 | $prefix = $this->optionalPrefix; 68 | $fileName = substr($fileName, \strlen($this->optionalPrefix)); 69 | } 70 | 71 | $expected = match ($this->case) { 72 | self::SNAKE_CASE => StringUtil::toSnakeCase($fileName), 73 | self::CAMEL_CASE => StringUtil::toCamelCase($fileName), 74 | self::PASCAL_CASE => StringUtil::toPascalCase($fileName), 75 | self::KEBAB_CASE => StringUtil::toKebabCase($fileName), 76 | }; 77 | 78 | if ($expected !== $fileName) { 79 | $this->addFileError( 80 | \sprintf('The file name must use %s; expected %s.', $this->case, $prefix.$expected), 81 | $token, 82 | ); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Rules/FixableRuleInterface.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 20 | if (!$token->isMatching(Token::BLOCK_NAME_TYPE, 'include')) { 21 | return; 22 | } 23 | 24 | $fixer = $this->addFixableError( 25 | 'Include function must be used instead of include tag.', 26 | $token 27 | ); 28 | if (null === $fixer) { 29 | return; 30 | } 31 | 32 | $openingTag = $tokens->findPrevious(Token::BLOCK_START_TYPE, $tokenIndex); 33 | Assert::notFalse($openingTag, 'Opening tag cannot be null.'); 34 | 35 | $closingTag = $tokens->findNext(Token::BLOCK_END_TYPE, $tokenIndex); 36 | Assert::notFalse($closingTag, 'Closing tag cannot be null.'); 37 | 38 | $fixer->beginChangeSet(); 39 | 40 | // Replace opening tag (and keep eventual whitespace modifiers) 41 | $fixer->replaceToken($openingTag, str_replace('{%', '{{', $tokens->get($openingTag)->getValue())); 42 | $fixer->replaceToken($tokenIndex, 'include('); 43 | 44 | $ignoreMissing = false; 45 | $withoutContext = false; 46 | $withVariable = false; 47 | foreach (range($tokenIndex, $closingTag) as $index) { 48 | $token = $tokens->get($index); 49 | if (!$token->isMatching(Token::NAME_TYPE)) { 50 | continue; 51 | } 52 | switch ($token->getValue()) { 53 | case 'with': 54 | $withVariable = true; 55 | $fixer->replaceToken($index, ','); 56 | break; 57 | case 'only': 58 | $withoutContext = true; 59 | $fixer->replaceToken($index, ''); 60 | break; 61 | case 'ignore': 62 | $ignoreMissing = true; 63 | $fixer->replaceToken($index, ''); 64 | break; 65 | case 'missing': 66 | $fixer->replaceToken($index, ''); 67 | break; 68 | } 69 | } 70 | 71 | $endInclude = ') '.str_replace('%}', '}}', $tokens->get($closingTag)->getValue()); 72 | if ($ignoreMissing) { 73 | $endInclude = ', true'.$endInclude; 74 | } 75 | if ($ignoreMissing || $withoutContext) { 76 | $endInclude = \sprintf(', %s', $withoutContext ? 'false' : 'true').$endInclude; 77 | 78 | if (!$withVariable) { 79 | $endInclude = ', []'.$endInclude; 80 | } 81 | } 82 | 83 | $fixer->replaceToken($closingTag, $endInclude); 84 | $fixer->endChangeSet(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Rules/Function/MacroArgumentNameRule.php: -------------------------------------------------------------------------------- 1 | $this->case, 34 | ]; 35 | } 36 | 37 | protected function process(int $tokenIndex, Tokens $tokens): void 38 | { 39 | $token = $tokens->get($tokenIndex); 40 | if (!$token->isMatching(Token::MACRO_VAR_NAME_TYPE)) { 41 | return; 42 | } 43 | 44 | $name = $token->getValue(); 45 | $expected = match ($this->case) { 46 | self::SNAKE_CASE => StringUtil::toSnakeCase($name), 47 | self::CAMEL_CASE => StringUtil::toCamelCase($name), 48 | self::PASCAL_CASE => StringUtil::toPascalCase($name), 49 | }; 50 | 51 | if ($expected !== $name) { 52 | $this->addError( 53 | \sprintf('The macro argument must use %s; expected %s.', $this->case, $expected), 54 | $token, 55 | ); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Rules/Function/NamedArgumentNameRule.php: -------------------------------------------------------------------------------- 1 | $this->case, 35 | ]; 36 | } 37 | 38 | protected function process(int $tokenIndex, Tokens $tokens): void 39 | { 40 | $token = $tokens->get($tokenIndex); 41 | if (!$token->isMatching(Token::NAMED_ARGUMENT_SEPARATOR_TYPE)) { 42 | return; 43 | } 44 | 45 | $nameTokenIndex = $tokens->findPrevious(Token::NAME_TYPE, $tokenIndex); 46 | Assert::notFalse($nameTokenIndex, 'A NAMED_ARGUMENT_SEPARATOR_TYPE always follow a name'); 47 | 48 | $name = $tokens->get($nameTokenIndex)->getValue(); 49 | $expected = match ($this->case) { 50 | self::SNAKE_CASE => StringUtil::toSnakeCase($name), 51 | self::CAMEL_CASE => StringUtil::toCamelCase($name), 52 | self::PASCAL_CASE => StringUtil::toPascalCase($name), 53 | }; 54 | 55 | if ($expected !== $name) { 56 | $this->addError( 57 | \sprintf('The named argument must use %s; expected %s.', $this->case, $expected), 58 | $token, 59 | ); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Rules/Function/NamedArgumentSeparatorRule.php: -------------------------------------------------------------------------------- 1 | = 3.12.0`). 14 | */ 15 | final class NamedArgumentSeparatorRule extends AbstractFixableRule 16 | { 17 | protected function process(int $tokenIndex, Tokens $tokens): void 18 | { 19 | if (!StubbedEnvironment::satisfiesTwigVersion(3, 12)) { 20 | // @codeCoverageIgnoreStart 21 | return; 22 | // @codeCoverageIgnoreEnd 23 | } 24 | 25 | $token = $tokens->get($tokenIndex); 26 | if (!$token->isMatching(Token::NAMED_ARGUMENT_SEPARATOR_TYPE)) { 27 | return; 28 | } 29 | 30 | if (':' === $token->getValue()) { 31 | return; 32 | } 33 | 34 | $fixer = $this->addFixableError( 35 | \sprintf('Named arguments should be declared with the separator "%s".', ':'), 36 | $token 37 | ); 38 | 39 | if (null === $fixer) { 40 | return; 41 | } 42 | 43 | $fixer->replaceToken($tokenIndex, ':'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Rules/Function/NamedArgumentSpacingRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 19 | if ($token->isMatching(Token::NAMED_ARGUMENT_SEPARATOR_TYPE)) { 20 | return 0; 21 | } 22 | 23 | return null; 24 | } 25 | 26 | protected function getSpaceAfter(int $tokenIndex, Tokens $tokens): ?int 27 | { 28 | $token = $tokens->get($tokenIndex); 29 | if ($token->isMatching(Token::NAMED_ARGUMENT_SEPARATOR_TYPE, '=')) { 30 | return 0; 31 | } 32 | if ($token->isMatching(Token::NAMED_ARGUMENT_SEPARATOR_TYPE, ':')) { 33 | return 1; 34 | } 35 | 36 | return null; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Rules/Literal/CompactHashRule.php: -------------------------------------------------------------------------------- 1 | $this->compact, 26 | ]; 27 | } 28 | 29 | protected function process(int $tokenIndex, Tokens $tokens): void 30 | { 31 | if ($this->compact) { 32 | $this->ensureImplicitHashKey($tokenIndex, $tokens); 33 | } else { 34 | $this->ensureExplicitHashKey($tokenIndex, $tokens); 35 | } 36 | } 37 | 38 | private function ensureExplicitHashKey(int $tokenIndex, Tokens $tokens): void 39 | { 40 | $token = $tokens->get($tokenIndex); 41 | if (!$token->isMatching(Token::HASH_KEY_NAME_TYPE)) { 42 | return; 43 | } 44 | 45 | $next = $tokens->findNext(Token::EMPTY_TOKENS, $tokenIndex + 1, exclude: true); 46 | Assert::notFalse($next, 'A hash key cannot be the last token.'); 47 | 48 | $nextToken = $tokens->get($next); 49 | if ($nextToken->isMatching(Token::PUNCTUATION_TYPE, ':')) { 50 | return; 51 | } 52 | 53 | $fixer = $this->addFixableError( 54 | \sprintf('Hash key "%s" should be explicit.', $token->getValue()), 55 | $token 56 | ); 57 | if (null !== $fixer) { 58 | $fixer->addContent($tokenIndex, ':'.$token->getValue()); 59 | } 60 | } 61 | 62 | private function ensureImplicitHashKey(int $tokenIndex, Tokens $tokens): void 63 | { 64 | $token = $tokens->get($tokenIndex); 65 | if (!$token->isMatching(Token::PUNCTUATION_TYPE, ':')) { 66 | return; 67 | } 68 | 69 | $previous = $tokens->findPrevious(Token::EMPTY_TOKENS, $tokenIndex - 1, exclude: true); 70 | Assert::notFalse($previous, 'A punctuation cannot be the first token.'); 71 | 72 | $previousToken = $tokens->get($previous); 73 | if (!$previousToken->isMatching(Token::HASH_KEY_NAME_TYPE)) { 74 | return; 75 | } 76 | 77 | $next = $tokens->findNext(Token::EMPTY_TOKENS, $tokenIndex + 1, exclude: true); 78 | Assert::notFalse($next, 'A punctuation cannot be the last token.'); 79 | 80 | $nextToken = $tokens->get($next); 81 | if (!$nextToken->isMatching(Token::NAME_TYPE)) { 82 | return; 83 | } 84 | 85 | if ($nextToken->getValue() !== $previousToken->getValue()) { 86 | return; 87 | } 88 | 89 | $separator = $tokens->findNext(Token::EMPTY_TOKENS, $next + 1, exclude: true); 90 | Assert::notFalse($separator, 'A name cannot be the last token.'); 91 | 92 | $separatorToken = $tokens->get($separator); 93 | if (!$separatorToken->isMatching(Token::PUNCTUATION_TYPE, ['}', ','])) { 94 | return; 95 | } 96 | 97 | $fixer = $this->addFixableError( 98 | \sprintf('Hash key "%s" should be implicit.', $previousToken->getValue()), 99 | $previousToken 100 | ); 101 | if (null !== $fixer) { 102 | $fixer->beginChangeSet(); 103 | $fixer->replaceToken($previous, ''); 104 | 105 | // Clean whitespaces after the key. 106 | $index = $previous + 1; 107 | while ($tokens->get($index)->isMatching(Token::INDENT_TOKENS)) { 108 | $fixer->replaceToken($index, ''); 109 | ++$index; 110 | } 111 | 112 | // Clean whitespaces before the `:`. 113 | $index = $tokenIndex - 1; 114 | while ($tokens->get($index)->isMatching(Token::INDENT_TOKENS)) { 115 | $fixer->replaceToken($index, ''); 116 | --$index; 117 | } 118 | 119 | $fixer->replaceToken($tokenIndex, ''); 120 | 121 | // Clean whitespaces after the `:`. 122 | $index = $tokenIndex + 1; 123 | while ($tokens->get($index)->isMatching(Token::INDENT_TOKENS)) { 124 | $fixer->replaceToken($index, ''); 125 | ++$index; 126 | } 127 | $fixer->endChangeSet(); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Rules/Literal/HashQuoteRule.php: -------------------------------------------------------------------------------- 1 | $this->useQuote, 28 | ]; 29 | } 30 | 31 | protected function process(int $tokenIndex, Tokens $tokens): void 32 | { 33 | $token = $tokens->get($tokenIndex); 34 | if (!$token->isMatching(Token::PUNCTUATION_TYPE, ':')) { 35 | return; 36 | } 37 | 38 | $previous = $tokens->findPrevious(Token::EMPTY_TOKENS, $tokenIndex - 1, exclude: true); 39 | Assert::notFalse($previous, 'A punctuation cannot be the first token.'); 40 | 41 | if ($this->useQuote) { 42 | $this->nameShouldBeString($previous, $tokens); 43 | } else { 44 | $this->stringShouldBeName($previous, $tokens); 45 | } 46 | } 47 | 48 | private function nameShouldBeString(int $tokenIndex, Tokens $tokens): void 49 | { 50 | $token = $tokens->get($tokenIndex); 51 | 52 | $value = $token->getValue(); 53 | $error = \sprintf('The hash key "%s" should be quoted.', $value); 54 | 55 | if ($token->isMatching(Token::NUMBER_TYPE)) { 56 | // A value like `012` or `12.3` is cast to `12` by twig, 57 | // so we let the developer chose the right value. 58 | $fixable = $this->isInteger($value); 59 | } elseif ($token->isMatching(Token::HASH_KEY_NAME_TYPE)) { 60 | $blockName = $tokens->findPrevious(Token::BLOCK_TOKENS, $tokenIndex - 1); 61 | if (false !== $blockName && $tokens->get($blockName)->isMatching(Token::BLOCK_NAME_TYPE, 'types')) { 62 | // {% types {'foo': 'int'} %} is not a valid syntax 63 | return; 64 | } 65 | 66 | $fixable = true; 67 | } else { 68 | return; 69 | } 70 | 71 | $fixer = $fixable 72 | ? $this->addFixableError($error, $token) 73 | : $this->addError($error, $token); 74 | 75 | if ($fixer instanceof FixerInterface) { 76 | $fixer->replaceToken($tokenIndex, '\''.$value.'\''); 77 | } 78 | } 79 | 80 | private function stringShouldBeName(int $tokenIndex, Tokens $tokens): void 81 | { 82 | $token = $tokens->get($tokenIndex); 83 | if (!$token->isMatching(Token::STRING_TYPE)) { 84 | return; 85 | } 86 | 87 | $expectedValue = substr($token->getValue(), 1, -1); 88 | if ( 89 | !$this->isInteger($expectedValue) 90 | && 1 !== preg_match('/^'.Tokenizer::NAME_PATTERN.'$/', $expectedValue) 91 | ) { 92 | return; 93 | } 94 | 95 | $fixer = $this->addFixableError( 96 | \sprintf('The hash key "%s" does not require to be quoted.', $expectedValue), 97 | $token 98 | ); 99 | if (null !== $fixer) { 100 | $fixer->replaceToken($tokenIndex, $expectedValue); 101 | } 102 | } 103 | 104 | private function isInteger(string $value): bool 105 | { 106 | return $value === (string) (int) $value; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Rules/Literal/SingleQuoteRule.php: -------------------------------------------------------------------------------- 1 | $this->skipStringContainingSingleQuote, 25 | ]; 26 | } 27 | 28 | protected function process(int $tokenIndex, Tokens $tokens): void 29 | { 30 | $token = $tokens->get($tokenIndex); 31 | if (!$token->isMatching(Token::STRING_TYPE)) { 32 | return; 33 | } 34 | 35 | $content = $token->getValue(); 36 | if ( 37 | !str_starts_with($content, '"') 38 | || str_contains($content, '\'') && $this->skipStringContainingSingleQuote 39 | ) { 40 | return; 41 | } 42 | 43 | $fixer = $this->addFixableError('String should be declared with single quotes.', $token); 44 | if (null === $fixer) { 45 | return; 46 | } 47 | 48 | $content = substr($content, 1, -1); 49 | $content = str_replace( 50 | ['\\"', '\\#{', '#\\{', '\\\'', '\''], 51 | ['"', '#{', '#{', '\'', '\\\''], 52 | $content 53 | ); 54 | $fixer->replaceToken($tokenIndex, '\''.$content.'\''); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Rules/Node/AbstractNodeRule.php: -------------------------------------------------------------------------------- 1 | report = $report; 21 | $this->ignoredViolations = $ignoredViolations; 22 | } 23 | 24 | public function enterNode(Node $node, Environment $env): Node 25 | { 26 | return $node; 27 | } 28 | 29 | public function leaveNode(Node $node, Environment $env): Node 30 | { 31 | return $node; 32 | } 33 | 34 | public function getPriority(): int 35 | { 36 | return 0; 37 | } 38 | 39 | final protected function addWarning(string $message, Node $node, ?string $messageId = null): bool 40 | { 41 | $templateName = $node->getTemplateName(); 42 | Assert::notNull($templateName, 'Parsed node should always have a source context.'); 43 | 44 | return $this->addMessage( 45 | Violation::LEVEL_WARNING, 46 | $message, 47 | $templateName, 48 | $node->getTemplateLine(), 49 | null, 50 | $messageId, 51 | ); 52 | } 53 | 54 | final protected function addError(string $message, Node $node, ?string $messageId = null): bool 55 | { 56 | $templateName = $node->getTemplateName(); 57 | Assert::notNull($templateName, 'Parsed node should always have a source context.'); 58 | 59 | return $this->addMessage( 60 | Violation::LEVEL_ERROR, 61 | $message, 62 | $templateName, 63 | $node->getTemplateLine(), 64 | null, 65 | $messageId, 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Rules/Node/ForbiddenBlockRule.php: -------------------------------------------------------------------------------- 1 | $blocks 18 | */ 19 | public function __construct( 20 | private array $blocks, 21 | ) { 22 | } 23 | 24 | public function getConfiguration(): array 25 | { 26 | return [ 27 | 'blocks' => $this->blocks, 28 | ]; 29 | } 30 | 31 | public function enterNode(Node $node, Environment $env): Node 32 | { 33 | $blockName = $node->getNodeTag(); 34 | if (null === $blockName) { 35 | return $node; 36 | } 37 | 38 | if (!\in_array($blockName, $this->blocks, true)) { 39 | return $node; 40 | } 41 | 42 | $this->addError( 43 | \sprintf('Block "%s" is not allowed.', $blockName), 44 | $node, 45 | ); 46 | 47 | return $node; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Rules/Node/ForbiddenFilterRule.php: -------------------------------------------------------------------------------- 1 | $filters 19 | */ 20 | public function __construct( 21 | private array $filters, 22 | ) { 23 | } 24 | 25 | public function getConfiguration(): array 26 | { 27 | return [ 28 | 'filters' => $this->filters, 29 | ]; 30 | } 31 | 32 | public function enterNode(Node $node, Environment $env): Node 33 | { 34 | if (!$node instanceof FilterExpression) { 35 | return $node; 36 | } 37 | 38 | $filterName = $node->hasAttribute('name') 39 | ? $node->getAttribute('name') 40 | // @codeCoverageIgnoreStart 41 | : $node->getNode('filter')->getAttribute('value'); // BC for twig/twig < 3.12 42 | // @codeCoverageIgnoreEnd 43 | if (!\in_array($filterName, $this->filters, true)) { 44 | return $node; 45 | } 46 | 47 | $this->addError( 48 | \sprintf('Filter "%s" is not allowed.', $filterName), 49 | $node, 50 | ); 51 | 52 | return $node; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Rules/Node/ForbiddenFunctionRule.php: -------------------------------------------------------------------------------- 1 | $functions 19 | */ 20 | public function __construct( 21 | private array $functions, 22 | ) { 23 | } 24 | 25 | public function getConfiguration(): array 26 | { 27 | return [ 28 | 'functions' => $this->functions, 29 | ]; 30 | } 31 | 32 | public function enterNode(Node $node, Environment $env): Node 33 | { 34 | if (!$node instanceof FunctionExpression) { 35 | return $node; 36 | } 37 | 38 | $functionName = $node->getAttribute('name'); 39 | if (!\in_array($functionName, $this->functions, true)) { 40 | return $node; 41 | } 42 | 43 | $this->addError( 44 | \sprintf('Function "%s" is not allowed.', $functionName), 45 | $node, 46 | ); 47 | 48 | return $node; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Rules/Node/NodeRuleInterface.php: -------------------------------------------------------------------------------- 1 | $ignoredViolations 15 | */ 16 | public function setReport(Report $report, array $ignoredViolations = []): void; 17 | } 18 | -------------------------------------------------------------------------------- /src/Rules/Node/ValidConstantFunctionRule.php: -------------------------------------------------------------------------------- 1 | getAttribute('name'); 24 | if ('constant' !== $functionName) { 25 | return $node; 26 | } 27 | 28 | $arguments = $node->getNode('arguments'); 29 | if ($arguments->hasNode('0')) { 30 | $argument = $arguments->getNode('0'); 31 | } elseif ($arguments->hasNode('constant')) { 32 | // Try for named parameters 33 | $argument = $arguments->getNode('constant'); 34 | } else { 35 | $this->addError( 36 | 'The first param of the function "constant()" is required.', 37 | $node, 38 | 'NoConstant' 39 | ); 40 | 41 | return $node; 42 | } 43 | if (!$argument instanceof ConstantExpression) { 44 | return $node; 45 | } 46 | 47 | $constant = $argument->getAttribute('value'); 48 | if (!\is_string($constant)) { 49 | $this->addError( 50 | 'The first param of the function "constant()" must be a string.', 51 | $node, 52 | 'StringConstant' 53 | ); 54 | 55 | return $node; 56 | } 57 | 58 | // The object to get the constant from cannot be resolved statically. 59 | if (1 !== $arguments->count()) { 60 | return $node; 61 | } 62 | 63 | if (\defined($constant)) { 64 | return $node; 65 | } 66 | 67 | if ('::class' === strtolower(substr($constant, -7))) { 68 | $this->addError( 69 | 'You cannot use the function "constant()" to resolve class names.', 70 | $node, 71 | 'ClassConstant' 72 | ); 73 | } else { 74 | $this->addError( 75 | \sprintf('Constant "%s" is undefined.', $constant), 76 | $node, 77 | 'ConstantUndefined' 78 | ); 79 | } 80 | 81 | return $node; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Rules/Operator/OperatorNameSpacingRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 19 | if (!$token->isMatching(Token::OPERATOR_TYPE)) { 20 | return; 21 | } 22 | 23 | $value = $token->getValue(); 24 | // Ignore multi lines operators 25 | if (1 === preg_match('/\r\n?|\n/', $value)) { 26 | return; 27 | } 28 | 29 | $newValue = preg_replace('#\s+#', ' ', $value); 30 | if (!\is_string($newValue) || $value === $newValue) { 31 | return; 32 | } 33 | 34 | $fixer = $this->addFixableError( 35 | 'A single line operator should not have consecutive spaces.', 36 | $token 37 | ); 38 | 39 | if (null === $fixer) { 40 | return; 41 | } 42 | 43 | $fixer->replaceToken($tokenIndex, $newValue); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Rules/Operator/OperatorSpacingRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 20 | if (!$token->isMatching(Token::OPERATOR_TYPE)) { 21 | return null; 22 | } 23 | 24 | if ($token->isMatching(Token::OPERATOR_TYPE, ['not', '-', '+'])) { 25 | return $this->isUnary($tokenIndex, $tokens) ? null : 1; 26 | } 27 | 28 | if ($token->isMatching(Token::OPERATOR_TYPE, '..')) { 29 | return 0; 30 | } 31 | 32 | if ($token->isMatching(Token::OPERATOR_TYPE, ':')) { 33 | $relatedToken = $token->getRelatedToken(); 34 | 35 | return null !== $relatedToken && '?' === $relatedToken->getValue() ? 1 : 0; 36 | } 37 | 38 | return 1; 39 | } 40 | 41 | protected function getSpaceAfter(int $tokenIndex, Tokens $tokens): ?int 42 | { 43 | $token = $tokens->get($tokenIndex); 44 | if (!$token->isMatching(Token::OPERATOR_TYPE)) { 45 | return null; 46 | } 47 | 48 | if ($token->isMatching(Token::OPERATOR_TYPE, ['-', '+'])) { 49 | return $this->isUnary($tokenIndex, $tokens) ? 0 : 1; 50 | } 51 | 52 | if ($token->isMatching(Token::OPERATOR_TYPE, '..')) { 53 | return 0; 54 | } 55 | 56 | if ($token->isMatching(Token::OPERATOR_TYPE, ':')) { 57 | $relatedToken = $token->getRelatedToken(); 58 | 59 | return null !== $relatedToken && '?' === $relatedToken->getValue() ? 1 : 0; 60 | } 61 | 62 | return 1; 63 | } 64 | 65 | private function isUnary(int $tokenIndex, Tokens $tokens): bool 66 | { 67 | $previous = $tokens->findPrevious(Token::EMPTY_TOKENS, $tokenIndex - 1, exclude: true); 68 | Assert::notFalse($previous, 'An OPERATOR_TYPE cannot be the first non-empty token'); 69 | 70 | $previousToken = $tokens->get($previous); 71 | 72 | return $previousToken->isMatching([ 73 | // {{ 1 * -2 }} 74 | Token::OPERATOR_TYPE, 75 | // {{ -2 }} 76 | Token::VAR_START_TYPE, 77 | // {% if -2 ... %} 78 | Token::BLOCK_NAME_TYPE, 79 | ]) 80 | // {{ 1 + (-2) }} 81 | || $previousToken->isMatching(Token::PUNCTUATION_TYPE, ['(', '[', ':', ',']); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Rules/Punctuation/PunctuationSpacingRule.php: -------------------------------------------------------------------------------- 1 | 0, 20 | ']' => 0, 21 | '}' => 0, 22 | ':' => 0, 23 | '.' => 0, 24 | ',' => 0, 25 | '|' => 0, 26 | '?:' => 0, 27 | ]; 28 | private const DEFAULT_SPACE_AFTER = [ 29 | '(' => 0, 30 | '[' => 0, 31 | '{' => 0, 32 | '.' => 0, 33 | '|' => 0, 34 | ':' => 1, 35 | ',' => 1, 36 | '?:' => 1, 37 | ]; 38 | 39 | /** @var array */ 40 | private array $punctuationWithSpaceBefore; 41 | 42 | /** @var array */ 43 | private array $punctuationWithSpaceAfter; 44 | 45 | /** 46 | * @param array $punctuationWithSpaceBefore 47 | * @param array $punctuationWithSpaceAfter 48 | */ 49 | public function __construct( 50 | array $punctuationWithSpaceBefore = [], 51 | array $punctuationWithSpaceAfter = [], 52 | ) { 53 | $this->punctuationWithSpaceBefore = $punctuationWithSpaceBefore + self::DEFAULT_SPACE_BEFORE; 54 | $this->punctuationWithSpaceAfter = $punctuationWithSpaceAfter + self::DEFAULT_SPACE_AFTER; 55 | } 56 | 57 | public function getConfiguration(): array 58 | { 59 | return [ 60 | 'before' => $this->punctuationWithSpaceBefore, 61 | 'after' => $this->punctuationWithSpaceAfter, 62 | ]; 63 | } 64 | 65 | protected function getSpaceBefore(int $tokenIndex, Tokens $tokens): ?int 66 | { 67 | $token = $tokens->get($tokenIndex); 68 | if (!$token->isMatching(Token::PUNCTUATION_TYPE)) { 69 | return null; 70 | } 71 | 72 | return $this->punctuationWithSpaceBefore[$token->getValue()] ?? null; 73 | } 74 | 75 | protected function getSpaceAfter(int $tokenIndex, Tokens $tokens): ?int 76 | { 77 | $token = $tokens->get($tokenIndex); 78 | if (!$token->isMatching(Token::PUNCTUATION_TYPE)) { 79 | return null; 80 | } 81 | 82 | $nextIndex = $tokens->findNext(Token::WHITESPACE_TOKENS, $tokenIndex + 1, exclude: true); 83 | Assert::notFalse($nextIndex, 'A PUNCTUATION_TYPE cannot be the last non-empty token'); 84 | 85 | // We cannot change spaces after a token, if the next one has a constraint: `[1,2,3,]`. 86 | if (null !== $this->getSpaceBefore($nextIndex, $tokens)) { 87 | return null; 88 | } 89 | 90 | return $this->punctuationWithSpaceAfter[$token->getValue()] ?? null; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Rules/Punctuation/TrailingCommaMultiLineRule.php: -------------------------------------------------------------------------------- 1 | $this->useTrailingComma, 26 | ]; 27 | } 28 | 29 | protected function process(int $tokenIndex, Tokens $tokens): void 30 | { 31 | $token = $tokens->get($tokenIndex); 32 | if ($token->isMatching(Token::PUNCTUATION_TYPE, ')')) { 33 | $related = $token->getRelatedToken(); 34 | Assert::notNull($related, 'A closer is always related to an opener.'); 35 | 36 | $relatedIndex = $tokens->getIndex($related); 37 | $relatedPrevious = $tokens->findPrevious(Token::EMPTY_TOKENS, $relatedIndex - 1, exclude: true); 38 | Assert::notFalse($relatedPrevious, 'An opener cannot be the first token.'); 39 | 40 | if (!$tokens->get($relatedPrevious)->isMatching([Token::FUNCTION_NAME_TYPE, Token::FILTER_NAME_TYPE])) { 41 | return; 42 | } 43 | } elseif (!$token->isMatching(Token::PUNCTUATION_TYPE, ['}', ']'])) { 44 | return; 45 | } 46 | 47 | $previous = $tokens->findPrevious(Token::EMPTY_TOKENS, $tokenIndex - 1, exclude: true); 48 | Assert::notFalse($previous, 'A closer cannot be the first token.'); 49 | 50 | if (false === $tokens->findNext(Token::EOL_TYPE, $previous, $tokenIndex)) { 51 | // The closer is on the same line as the last element. 52 | return; 53 | } 54 | 55 | $previousToken = $tokens->get($previous); 56 | if ($previousToken === $token->getRelatedToken()) { 57 | // There is no element, so adding a trailing comma will break the code. 58 | return; 59 | } 60 | 61 | $isMatchingComma = $previousToken->isMatching(Token::PUNCTUATION_TYPE, ','); 62 | if ($this->useTrailingComma === $isMatchingComma) { 63 | return; 64 | } 65 | 66 | $fixer = $this->addFixableError( 67 | $this->useTrailingComma 68 | ? 'Multi-line arrays, objects and parameters lists should have trailing comma.' 69 | : 'Multi-line arrays, objects and parameters lists should not have trailing comma.', 70 | $this->useTrailingComma 71 | ? $tokens->get($previous + 1) 72 | : $tokens->get($previous) 73 | ); 74 | 75 | if (null === $fixer) { 76 | return; 77 | } 78 | 79 | if ($this->useTrailingComma) { 80 | $fixer->addContent($previous, ','); 81 | } else { 82 | $fixer->replaceToken($previous, ''); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Rules/Punctuation/TrailingCommaSingleLineRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 20 | if (!$token->isMatching(Token::PUNCTUATION_TYPE, [')', '}', ']'])) { 21 | return; 22 | } 23 | 24 | $previous = $tokens->findPrevious(Token::EMPTY_TOKENS, $tokenIndex - 1, exclude: true); 25 | Assert::notFalse($previous, 'A closer cannot be the first token.'); 26 | 27 | if (false !== $tokens->findNext(Token::EOL_TYPE, $previous, $tokenIndex)) { 28 | // The closer is on a different line than the last element. 29 | return; 30 | } 31 | 32 | if (!$tokens->get($previous)->isMatching(Token::PUNCTUATION_TYPE, ',')) { 33 | return; 34 | } 35 | 36 | $fixer = $this->addFixableError( 37 | 'Single-line arrays, objects and parameters lists should not have trailing comma.', 38 | $tokens->get($previous) 39 | ); 40 | 41 | if (null === $fixer) { 42 | return; 43 | } 44 | 45 | $fixer->replaceToken($previous, ''); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Rules/RuleInterface.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private array $ignoredViolations = []; 19 | 20 | public function getName(): string 21 | { 22 | return static::class; 23 | } 24 | 25 | public function getShortName(): string 26 | { 27 | $shortName = (new \ReflectionClass($this))->getShortName(); 28 | 29 | return str_ends_with($shortName, 'Rule') ? substr($shortName, 0, -4) : $shortName; 30 | } 31 | 32 | private function addMessage( 33 | int $messageType, 34 | string $message, 35 | string $fileName, 36 | ?int $line = null, 37 | ?int $linePosition = null, 38 | ?string $messageId = null, 39 | ): bool { 40 | $id = new ViolationId( 41 | $this->getShortName(), 42 | $messageId ?? ucfirst(strtolower(Violation::getLevelAsString($messageType))), 43 | $line, 44 | $linePosition, 45 | ); 46 | foreach ($this->ignoredViolations as $ignoredViolation) { 47 | if ($ignoredViolation->match($id)) { 48 | return false; 49 | } 50 | } 51 | 52 | $report = $this->report; 53 | if (null !== $report) { // The report is null when we are fixing the file. 54 | $violation = new Violation( 55 | $messageType, 56 | $message, 57 | $fileName, 58 | $this->getName(), 59 | $id, 60 | ); 61 | 62 | $report->addViolation($violation); 63 | } 64 | 65 | return true; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Rules/Variable/VariableNameRule.php: -------------------------------------------------------------------------------- 1 | $this->case, 37 | 'optionalPrefix' => $this->optionalPrefix, 38 | ]; 39 | } 40 | 41 | protected function process(int $tokenIndex, Tokens $tokens): void 42 | { 43 | $token = $tokens->get($tokenIndex); 44 | 45 | if ($token->isMatching(Token::BLOCK_NAME_TYPE, 'set')) { 46 | $nameTokenIndex = $tokens->findNext(Token::NAME_TYPE, $tokenIndex); 47 | Assert::notFalse($nameTokenIndex, 'A BLOCK_NAME_TYPE "set" must be followed by a name'); 48 | 49 | $this->validateVariable($tokens->get($nameTokenIndex)); 50 | } elseif ($token->isMatching(Token::BLOCK_NAME_TYPE, 'for')) { 51 | $nameTokenIndex = $tokens->findNext(Token::NAME_TYPE, $tokenIndex); 52 | Assert::notFalse($nameTokenIndex, 'A BLOCK_NAME_TYPE "for" must be followed by a name'); 53 | 54 | $secondNameTokenIndex = $tokens->findNext([Token::NAME_TYPE, Token::OPERATOR_TYPE], $nameTokenIndex + 1); 55 | Assert::notFalse($secondNameTokenIndex, 'A BLOCK_NAME_TYPE "for" must use the "in" operator'); 56 | 57 | $this->validateVariable($tokens->get($nameTokenIndex)); 58 | if ($tokens->get($secondNameTokenIndex)->isMatching(Token::NAME_TYPE)) { 59 | $this->validateVariable($tokens->get($secondNameTokenIndex)); 60 | } 61 | } 62 | } 63 | 64 | private function validateVariable(Token $token): void 65 | { 66 | $name = $token->getValue(); 67 | $prefix = ''; 68 | if (str_starts_with($name, $this->optionalPrefix)) { 69 | $prefix = $this->optionalPrefix; 70 | $name = substr($name, \strlen($this->optionalPrefix)); 71 | } 72 | 73 | $expected = match ($this->case) { 74 | self::SNAKE_CASE => StringUtil::toSnakeCase($name), 75 | self::CAMEL_CASE => StringUtil::toCamelCase($name), 76 | self::PASCAL_CASE => StringUtil::toPascalCase($name), 77 | }; 78 | 79 | if ($expected !== $name) { 80 | $this->addError( 81 | \sprintf('The var name must use %s; expected %s.', $this->case, $prefix.$expected), 82 | $token, 83 | ); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Rules/Whitespace/BlankEOFRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 19 | if (!$token->isMatching(Token::EOF_TYPE)) { 20 | return; 21 | } 22 | 23 | $previous = $tokens->findPrevious(Token::EOL_TYPE, $tokenIndex - 1, exclude: true); 24 | if (false === $previous) { 25 | // If all previous tokens are EOL_TYPE, we have to count one more 26 | // since $tokenIndex start at 0 27 | $i = $tokenIndex + 1; 28 | } else { 29 | $i = $tokenIndex - $previous - 1; 30 | } 31 | 32 | // Only 0 or 2+ blank lines are reported. 33 | if (1 === $i) { 34 | return; 35 | } 36 | 37 | $fixer = $this->addFixableError( 38 | \sprintf('A file must end with 1 blank line; found %d', $i), 39 | $token 40 | ); 41 | 42 | if (null === $fixer) { 43 | return; 44 | } 45 | 46 | // Because we added manually extra empty lines to the count 47 | $i = min($i, $tokenIndex); 48 | 49 | if (0 === $i) { 50 | $fixer->addNewlineBefore($tokenIndex); 51 | } else { 52 | $fixer->beginChangeSet(); 53 | while ($i >= 2 || $i === $tokenIndex) { 54 | $fixer->replaceToken($tokenIndex - $i, ''); 55 | --$i; 56 | } 57 | $fixer->endChangeSet(); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Rules/Whitespace/EmptyLinesRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 19 | if (!$token->isMatching(Token::EOL_TYPE)) { 20 | return; 21 | } 22 | 23 | if ($tokens->get($tokenIndex + 1)->isMatching(Token::EOL_TYPE)) { 24 | // Rely on the next token check instead to avoid duplicate errors 25 | return; 26 | } 27 | 28 | $previous = $tokens->findPrevious(Token::EOL_TYPE, $tokenIndex, exclude: true); 29 | if (false === $previous) { 30 | // If all previous tokens are EOL_TYPE, we have to count one more 31 | // since $tokenIndex start at 0 32 | $i = $tokenIndex + 1; 33 | } else { 34 | $i = $tokenIndex - $previous - 1; 35 | } 36 | 37 | if ($i < 2) { 38 | return; 39 | } 40 | 41 | $fixer = $this->addFixableError( 42 | \sprintf('More than 1 empty line is not allowed, found %d', $i), 43 | $token 44 | ); 45 | 46 | if (null === $fixer) { 47 | return; 48 | } 49 | 50 | // Because we added manually extra empty lines to the count 51 | $i = min($i, $tokenIndex); 52 | 53 | $fixer->beginChangeSet(); 54 | while ($i >= 2 || $i === $tokenIndex) { 55 | $fixer->replaceToken($tokenIndex - $i, ''); 56 | --$i; 57 | } 58 | $fixer->endChangeSet(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Rules/Whitespace/IndentRule.php: -------------------------------------------------------------------------------- 1 | $this->spaceRatio, 27 | 'useTab' => $this->useTab, 28 | ]; 29 | } 30 | 31 | protected function process(int $tokenIndex, Tokens $tokens): void 32 | { 33 | if ($this->useTab) { 34 | $this->spaceToTab($tokenIndex, $tokens); 35 | } else { 36 | $this->tabToSpace($tokenIndex, $tokens); 37 | } 38 | } 39 | 40 | private function tabToSpace(int $tokenIndex, Tokens $tokens): void 41 | { 42 | $token = $tokens->get($tokenIndex); 43 | if (!$token->isMatching(Token::TAB_TOKENS)) { 44 | return; 45 | } 46 | 47 | $fixer = $this->addFixableError('A file must not be indented with tabs.', $token); 48 | if (null === $fixer) { 49 | return; 50 | } 51 | 52 | $fixer->replaceToken( 53 | $tokenIndex, 54 | str_replace("\t", str_repeat(' ', $this->spaceRatio), $token->getValue()) 55 | ); 56 | } 57 | 58 | private function spaceToTab(int $tokenIndex, Tokens $tokens): void 59 | { 60 | $token = $tokens->get($tokenIndex); 61 | if (1 !== $token->getLinePosition()) { 62 | return; 63 | } 64 | 65 | if (!$token->isMatching(Token::WHITESPACE_TOKENS)) { 66 | return; 67 | } 68 | 69 | if (\strlen($token->getValue()) < $this->spaceRatio) { 70 | return; 71 | } 72 | 73 | $fixer = $this->addFixableError('A file must not be indented with spaces.', $token); 74 | if (null === $fixer) { 75 | return; 76 | } 77 | 78 | $fixer->replaceToken( 79 | $tokenIndex, 80 | str_replace(str_repeat(' ', $this->spaceRatio), "\t", $token->getValue()) 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Rules/Whitespace/TrailingSpaceRule.php: -------------------------------------------------------------------------------- 1 | get($tokenIndex); 19 | if (!$token->isMatching(Token::EOL_TOKENS)) { 20 | return; 21 | } 22 | 23 | if ( 24 | !$tokens->has($tokenIndex - 1) 25 | || !$tokens->get($tokenIndex - 1)->isMatching(Token::INDENT_TOKENS) 26 | ) { 27 | return; 28 | } 29 | 30 | $fixer = $this->addFixableError( 31 | 'A line should not end with blank space(s).', 32 | $token 33 | ); 34 | 35 | if (null === $fixer) { 36 | return; 37 | } 38 | 39 | $fixer->replaceToken($tokenIndex - 1, ''); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Ruleset/Ruleset.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | private array $rules = []; 22 | 23 | private bool $allowNonFixableRules = true; 24 | 25 | public function allowNonFixableRules(bool $allowNonFixableRules = true): self 26 | { 27 | $this->allowNonFixableRules = $allowNonFixableRules; 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * @return list 34 | */ 35 | public function getRules(): array 36 | { 37 | if (!$this->allowNonFixableRules) { 38 | return array_values(array_filter( 39 | $this->rules, 40 | static fn ($rule): bool => $rule instanceof FixableRuleInterface, 41 | )); 42 | } 43 | 44 | return array_values($this->rules); 45 | } 46 | 47 | /** 48 | * @return $this 49 | */ 50 | public function addRule(RuleInterface|NodeRuleInterface $rule): self 51 | { 52 | $config = $rule instanceof ConfigurableRuleInterface 53 | ? $rule->getConfiguration() 54 | : null; 55 | $key = $rule::class.md5(serialize($config)); 56 | 57 | $this->rules[$key] = $rule; 58 | 59 | return $this; 60 | } 61 | 62 | /** 63 | * @return $this 64 | */ 65 | public function overrideRule(RuleInterface|NodeRuleInterface $rule): self 66 | { 67 | $this->removeRule($rule::class); 68 | $this->addRule($rule); 69 | 70 | return $this; 71 | } 72 | 73 | /** 74 | * @param class-string $class 75 | * 76 | * @return $this 77 | */ 78 | public function removeRule(string $class): self 79 | { 80 | foreach ($this->rules as $key => $rule) { 81 | if ($rule::class === $class) { 82 | unset($this->rules[$key]); 83 | } 84 | } 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * @return $this 91 | */ 92 | public function addStandard(StandardInterface $standard): self 93 | { 94 | foreach ($standard->getRules() as $rule) { 95 | $this->addRule($rule); 96 | } 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * @return $this 103 | */ 104 | public function overrideStandard(StandardInterface $standard): self 105 | { 106 | foreach ($standard->getRules() as $rule) { 107 | $this->overrideRule($rule); 108 | } 109 | 110 | return $this; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Runner/Fixer.php: -------------------------------------------------------------------------------- 1 | 36 | */ 37 | private array $tokens = []; 38 | 39 | /** 40 | * A list of tokens that have already been fixed. 41 | * 42 | * We don't allow the same token to be fixed more than once each time through a file 43 | * as this can easily cause conflicts between rules. 44 | * 45 | * @var array 46 | */ 47 | private array $fixedTokens = []; 48 | 49 | /** 50 | * The last value of each fixed token. 51 | * 52 | * If a token is being "fixed" back to its last value, the fix is probably conflicting with another. 53 | * 54 | * @var array 55 | */ 56 | private array $oldTokenValues = []; 57 | 58 | /** 59 | * A list of tokens that have been fixed during a change set. 60 | * 61 | * All changes in change set must be able to be applied, or else the entire change set is rejected. 62 | * 63 | * @var array 64 | */ 65 | private array $changeSet = []; 66 | 67 | /** 68 | * Is there an open change set. 69 | */ 70 | private bool $inChangeSet = false; 71 | 72 | /** 73 | * Is the current fixing loop in conflict? 74 | */ 75 | private bool $inConflict = false; 76 | 77 | public function __construct(private TokenizerInterface $tokenizer) 78 | { 79 | } 80 | 81 | public function fixFile(string $content, Ruleset $ruleset): string 82 | { 83 | $this->loops = 0; 84 | do { 85 | $this->inConflict = false; 86 | 87 | $twigSource = new Source($content, 'TwigCsFixer'); 88 | $stream = $this->tokenizer->tokenize($twigSource); 89 | 90 | $this->startFile($stream); 91 | 92 | $rules = $ruleset->getRules(); 93 | foreach ($rules as $rule) { 94 | if ($rule instanceof FixableRuleInterface) { 95 | $rule->fixFile($stream, $this); 96 | } 97 | } 98 | 99 | ++$this->loops; 100 | $content = $this->getContent(); 101 | $numFixes = \count($this->fixedTokens); 102 | } while ( 103 | (0 !== $numFixes || $this->inConflict) 104 | && $this->loops < self::MAX_FIXER_ITERATION 105 | ); 106 | 107 | if ($numFixes > 0) { 108 | throw CannotFixFileException::infiniteLoop(); 109 | } 110 | 111 | return $content; 112 | } 113 | 114 | public function beginChangeSet(): void 115 | { 116 | if ($this->inChangeSet) { 117 | throw new \BadMethodCallException('Already in change set.'); 118 | } 119 | 120 | $this->changeSet = []; 121 | $this->inChangeSet = true; 122 | } 123 | 124 | public function endChangeSet(): void 125 | { 126 | if (!$this->inChangeSet) { 127 | throw new \BadMethodCallException('There is no current change set.'); 128 | } 129 | 130 | $this->inChangeSet = false; 131 | 132 | if (!$this->inConflict) { 133 | $applied = []; 134 | foreach ($this->changeSet as $tokenIndex => $content) { 135 | $success = $this->replaceToken($tokenIndex, $content); 136 | if (!$success) { 137 | // Rolling back all changes. 138 | foreach ($applied as $appliedTokenIndex) { 139 | $this->revertToken($appliedTokenIndex); 140 | } 141 | break; 142 | } 143 | 144 | $applied[] = $tokenIndex; 145 | } 146 | } 147 | 148 | $this->changeSet = []; 149 | } 150 | 151 | public function replaceToken(int $tokenIndex, string $content): bool 152 | { 153 | if ($this->inConflict) { 154 | return false; 155 | } 156 | 157 | if (!$this->inChangeSet && isset($this->fixedTokens[$tokenIndex])) { 158 | return false; 159 | } 160 | 161 | if ($this->inChangeSet) { 162 | $this->changeSet[$tokenIndex] = $content; 163 | 164 | return true; 165 | } 166 | 167 | if (!isset($this->oldTokenValues[$tokenIndex])) { 168 | $this->oldTokenValues[$tokenIndex] = [ 169 | 'prev' => $this->tokens[$tokenIndex], 170 | 'curr' => $content, 171 | 'loop' => $this->loops, 172 | ]; 173 | } elseif ( 174 | $content === $this->oldTokenValues[$tokenIndex]['prev'] 175 | && ($this->loops - 1) === $this->oldTokenValues[$tokenIndex]['loop'] 176 | ) { 177 | $this->inConflict = true; 178 | 179 | return false; 180 | } else { 181 | $this->oldTokenValues[$tokenIndex]['prev'] = $this->oldTokenValues[$tokenIndex]['curr']; 182 | $this->oldTokenValues[$tokenIndex]['curr'] = $content; 183 | $this->oldTokenValues[$tokenIndex]['loop'] = $this->loops; 184 | } 185 | 186 | $this->fixedTokens[$tokenIndex] = $this->tokens[$tokenIndex]; 187 | $this->tokens[$tokenIndex] = $content; 188 | 189 | return true; 190 | } 191 | 192 | public function addNewline(int $tokenIndex): bool 193 | { 194 | $current = $this->getTokenContent($tokenIndex); 195 | 196 | return $this->replaceToken($tokenIndex, $current.$this->eolChar); 197 | } 198 | 199 | public function addNewlineBefore(int $tokenIndex): bool 200 | { 201 | $current = $this->getTokenContent($tokenIndex); 202 | 203 | return $this->replaceToken($tokenIndex, $this->eolChar.$current); 204 | } 205 | 206 | public function addContent(int $tokenIndex, string $content): bool 207 | { 208 | $current = $this->getTokenContent($tokenIndex); 209 | 210 | return $this->replaceToken($tokenIndex, $current.$content); 211 | } 212 | 213 | public function addContentBefore(int $tokenIndex, string $content): bool 214 | { 215 | $current = $this->getTokenContent($tokenIndex); 216 | 217 | return $this->replaceToken($tokenIndex, $content.$current); 218 | } 219 | 220 | private function startFile(Tokens $tokens): void 221 | { 222 | $this->fixedTokens = []; 223 | 224 | $this->tokens = array_map(static fn (Token $token): string => $token->getValue(), $tokens->toArray()); 225 | 226 | $this->eolChar = FileHelper::detectEOL($this->getContent()); 227 | } 228 | 229 | private function getContent(): string 230 | { 231 | return implode('', $this->tokens); 232 | } 233 | 234 | /** 235 | * This function takes change sets into account so should be used 236 | * instead of directly accessing the token array. 237 | */ 238 | private function getTokenContent(int $tokenIndex): string 239 | { 240 | if ($this->inChangeSet && isset($this->changeSet[$tokenIndex])) { 241 | return $this->changeSet[$tokenIndex]; 242 | } 243 | 244 | return $this->tokens[$tokenIndex]; 245 | } 246 | 247 | private function revertToken(int $tokenIndex): void 248 | { 249 | $errorMessage = \sprintf('Nothing to revert at index %s', $tokenIndex); 250 | Assert::keyExists($this->fixedTokens, $tokenIndex, $errorMessage); 251 | 252 | $this->tokens[$tokenIndex] = $this->fixedTokens[$tokenIndex]; 253 | unset($this->fixedTokens[$tokenIndex]); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/Runner/FixerInterface.php: -------------------------------------------------------------------------------- 1 | cacheManager = $cacheManager ?? new NullCacheManager(); 37 | } 38 | 39 | /** 40 | * @param iterable<\SplFileInfo> $files 41 | */ 42 | public function run(iterable $files, Ruleset $ruleset, ?FixerInterface $fixer = null): Report 43 | { 44 | $report = new Report($files); 45 | 46 | $rules = array_filter($ruleset->getRules(), static fn ($rule) => $rule instanceof RuleInterface); 47 | $nodeVisitorRules = array_filter($ruleset->getRules(), static fn ($rule) => $rule instanceof NodeRuleInterface); 48 | 49 | $traverser = new NodeTraverser($this->env, $nodeVisitorRules); 50 | 51 | // Process 52 | foreach ($files as $file) { 53 | $filePath = $file->getPathname(); 54 | 55 | $content = @file_get_contents($filePath); 56 | if (false === $content) { 57 | $violation = new Violation( 58 | Violation::LEVEL_FATAL, 59 | 'Unable to read file.', 60 | $filePath 61 | ); 62 | 63 | $report->addViolation($violation); 64 | continue; 65 | } 66 | 67 | if (!$this->cacheManager->needFixing($filePath, $content)) { 68 | continue; 69 | } 70 | 71 | if (null !== $fixer) { 72 | try { 73 | $newContent = $fixer->fixFile($content, $ruleset); 74 | // Don't write the file if it is unchanged in order not to invalidate mtime based caches. 75 | if ($newContent !== $content) { 76 | $node = null; 77 | file_put_contents($filePath, $newContent); 78 | $content = $newContent; 79 | $report->addFixedFile($filePath); 80 | } 81 | } catch (CannotTokenizeException $exception) { 82 | $violation = new Violation( 83 | Violation::LEVEL_FATAL, 84 | \sprintf('Unable to tokenize file: %s', $exception->getMessage()), 85 | $filePath 86 | ); 87 | 88 | $report->addViolation($violation); 89 | continue; 90 | } catch (CannotFixFileException $exception) { 91 | $violation = new Violation( 92 | Violation::LEVEL_FATAL, 93 | \sprintf('Unable to fix file: %s', $exception->getMessage()), 94 | $filePath 95 | ); 96 | 97 | $report->addViolation($violation); 98 | } 99 | } 100 | 101 | // Tokenize file in order to lint. 102 | $this->setErrorHandler($report, $filePath); 103 | try { 104 | $twigSource = new Source($content, $filePath); 105 | $stream = $this->tokenizer->tokenize($twigSource); 106 | } catch (CannotTokenizeException $exception) { 107 | $violation = new Violation( 108 | Violation::LEVEL_FATAL, 109 | \sprintf('Unable to tokenize file: %s', $exception->getMessage()), 110 | $filePath 111 | ); 112 | 113 | $report->addViolation($violation); 114 | continue; 115 | } finally { 116 | restore_error_handler(); 117 | } 118 | 119 | foreach ($rules as $rule) { 120 | $rule->lintFile($stream, $report); 121 | } 122 | 123 | if ([] !== $nodeVisitorRules) { 124 | $node = $this->parseTemplate($content, $filePath, $report); 125 | if (null === $node) { 126 | continue; 127 | } 128 | 129 | foreach ($nodeVisitorRules as $nodeVisitor) { 130 | $nodeVisitor->setReport($report, $stream->getIgnoredViolations()); 131 | } 132 | 133 | $traverser->traverse($node); 134 | } 135 | 136 | // Only cache the file if there is no error in order to 137 | // - still see the errors when running again the linter 138 | // - still having the possibility to fix the file 139 | if ([] === $report->getFileViolations($filePath)) { 140 | $this->cacheManager->setFile($filePath, $content); 141 | } 142 | } 143 | 144 | return $report; 145 | } 146 | 147 | private function parseTemplate(string $content, string $filePath, Report $report): ?ModuleNode 148 | { 149 | try { 150 | $twigSource = new Source($content, $filePath); 151 | 152 | $node = $this->env->parse($this->env->tokenize($twigSource)); 153 | } catch (Error $error) { 154 | $violation = new Violation( 155 | Violation::LEVEL_FATAL, 156 | \sprintf('File is invalid: %s', $error->getRawMessage()), 157 | $filePath, 158 | null, 159 | new ViolationId(line: $error->getTemplateLine()) 160 | ); 161 | 162 | $report->addViolation($violation); 163 | 164 | return null; 165 | } 166 | 167 | // BC fix for twig/twig < 3.10. 168 | $sourceContext = $node->getSourceContext(); 169 | if (null !== $sourceContext) { 170 | $node->setSourceContext($sourceContext); 171 | } 172 | 173 | return $node; 174 | } 175 | 176 | private function setErrorHandler(Report $report, string $file): void 177 | { 178 | set_error_handler(static function (int $type, string $message) use ($report, $file): bool { 179 | $violation = new Violation( 180 | Violation::LEVEL_NOTICE, 181 | $message, 182 | $file 183 | ); 184 | 185 | $report->addViolation($violation); 186 | 187 | return true; 188 | }, \E_USER_DEPRECATED); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Standard/StandardInterface.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function getRules(): array; 16 | } 17 | -------------------------------------------------------------------------------- /src/Standard/Symfony.php: -------------------------------------------------------------------------------- 1 | getRules(), 26 | new FileNameRule(baseDirectory: 'templates', ignoredSubDirectories: ['broadcast', 'bundles', 'components'], optionalPrefix: '_'), 27 | new FileNameRule(case: DirectoryNameRule::PASCAL_CASE, baseDirectory: 'templates/broadcast'), 28 | new FileNameRule(case: DirectoryNameRule::PASCAL_CASE, baseDirectory: 'templates/components'), 29 | new DirectoryNameRule(baseDirectory: 'templates', ignoredSubDirectories: ['bundles', 'components']), 30 | new DirectoryNameRule(case: DirectoryNameRule::PASCAL_CASE, baseDirectory: 'templates/components'), 31 | new FileExtensionRule(), 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Standard/Twig.php: -------------------------------------------------------------------------------- 1 | getRules(), 28 | new BlankEOFRule(), 29 | new BlockNameSpacingRule(), 30 | new EmptyLinesRule(), 31 | new CompactHashRule(), 32 | new HashQuoteRule(), 33 | new IncludeFunctionRule(), 34 | new IndentRule(), 35 | new SingleQuoteRule(), 36 | new TrailingCommaMultiLineRule(), 37 | new TrailingCommaSingleLineRule(), 38 | new TrailingSpaceRule(), 39 | ]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Test/AbstractRuleTestCase.php: -------------------------------------------------------------------------------- 1 | $rules 23 | * @param array $expects 24 | * 25 | * @throws CannotFixFileException 26 | * @throws CannotTokenizeException 27 | */ 28 | protected function checkRule( 29 | RuleInterface|NodeRuleInterface|array $rules, 30 | array $expects, 31 | ?string $filePath = null, 32 | string|false|null $fixedFilePath = null, 33 | ): void { 34 | $env = new StubbedEnvironment(); 35 | $tokenizer = new Tokenizer($env); 36 | $linter = new Linter($env, $tokenizer); 37 | $ruleset = new Ruleset(); 38 | 39 | $filePath ??= $this->generateFilePath(); 40 | 41 | if (\is_array($rules)) { 42 | foreach ($rules as $rule) { 43 | $ruleset->addRule($rule); 44 | } 45 | } else { 46 | $ruleset->addRule($rules); 47 | } 48 | 49 | $report = $linter->run([new \SplFileInfo($filePath)], $ruleset); 50 | 51 | if (false !== $fixedFilePath) { 52 | $fixedFilePath ??= substr($filePath, 0, -5).'.fixed.twig'; 53 | if (file_exists($fixedFilePath)) { 54 | $content = file_get_contents($filePath); 55 | static::assertNotFalse($content); 56 | $fixer = new Fixer($tokenizer); 57 | 58 | $diff = TestHelper::generateDiff($fixer->fixFile($content, $ruleset), $fixedFilePath); 59 | if ('' !== $diff) { 60 | static::fail($diff); 61 | } 62 | } 63 | } 64 | 65 | $violations = $report->getFileViolations($filePath); 66 | 67 | /** @var array $messages */ 68 | $messages = []; 69 | foreach ($violations as $violation) { 70 | $message = $violation->getMessage(); 71 | if (Violation::LEVEL_FATAL === $violation->getLevel()) { 72 | $line = $violation->getLine(); 73 | 74 | if (null !== $line) { 75 | $message = \sprintf('Line %s: %s', $line, $message); 76 | } 77 | static::fail($message); 78 | } 79 | 80 | $id = $violation->getIdentifier()?->toString() ?? ''; 81 | if (isset($messages[$id])) { 82 | static::fail(\sprintf( 83 | 'Two violations have the same identifier "%s": the messages are "%s" and "%s".', 84 | $id, 85 | $messages[$id], 86 | $message, 87 | )); 88 | } 89 | 90 | $messages[$id] = $message; 91 | } 92 | 93 | static::assertSame($expects, $messages); 94 | } 95 | 96 | private function generateFilePath(): string 97 | { 98 | $class = new \ReflectionClass(static::class); 99 | $className = $class->getShortName(); 100 | $filename = $class->getFileName(); 101 | static::assertNotFalse($filename); 102 | 103 | $directory = \dirname($filename); 104 | 105 | return "{$directory}/{$className}.twig"; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Test/TestHelper.php: -------------------------------------------------------------------------------- 1 | "\033[31m{$line}\033[0m", 70 | '+' => "\033[32m{$line}\033[0m", 71 | default => $line, 72 | }; 73 | } 74 | } 75 | 76 | return implode(\PHP_EOL, $diff); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Token/Token.php: -------------------------------------------------------------------------------- 1 | self::WHITESPACE_TYPE, 55 | self::COMMENT_WHITESPACE_TYPE => self::COMMENT_WHITESPACE_TYPE, 56 | self::INLINE_COMMENT_WHITESPACE_TYPE => self::INLINE_COMMENT_WHITESPACE_TYPE, 57 | ]; 58 | 59 | public const TAB_TOKENS = [ 60 | self::TAB_TYPE => self::TAB_TYPE, 61 | self::COMMENT_TAB_TYPE => self::COMMENT_TAB_TYPE, 62 | self::INLINE_COMMENT_TAB_TYPE => self::INLINE_COMMENT_TAB_TYPE, 63 | ]; 64 | 65 | public const INDENT_TOKENS = self::WHITESPACE_TOKENS + self::TAB_TOKENS; 66 | 67 | public const EOL_TOKENS = [ 68 | self::EOL_TYPE => self::EOL_TYPE, 69 | self::COMMENT_EOL_TYPE => self::COMMENT_EOL_TYPE, 70 | ]; 71 | 72 | public const COMMENT_TOKENS = [ 73 | self::COMMENT_START_TYPE => self::COMMENT_START_TYPE, 74 | self::COMMENT_TEXT_TYPE => self::COMMENT_TEXT_TYPE, 75 | self::COMMENT_WHITESPACE_TYPE => self::COMMENT_WHITESPACE_TYPE, 76 | self::COMMENT_TAB_TYPE => self::COMMENT_TAB_TYPE, 77 | self::COMMENT_EOL_TYPE => self::COMMENT_EOL_TYPE, 78 | self::COMMENT_END_TYPE => self::COMMENT_END_TYPE, 79 | self::INLINE_COMMENT_START_TYPE => self::INLINE_COMMENT_START_TYPE, 80 | self::INLINE_COMMENT_TEXT_TYPE => self::INLINE_COMMENT_TEXT_TYPE, 81 | self::INLINE_COMMENT_WHITESPACE_TYPE => self::INLINE_COMMENT_WHITESPACE_TYPE, 82 | self::INLINE_COMMENT_TAB_TYPE => self::INLINE_COMMENT_TAB_TYPE, 83 | ]; 84 | 85 | public const EMPTY_TOKENS = self::INDENT_TOKENS + self::EOL_TOKENS + self::COMMENT_TOKENS; 86 | 87 | public const BLOCK_TOKENS = [ 88 | self::BLOCK_START_TYPE => self::BLOCK_START_TYPE, 89 | self::BLOCK_NAME_TYPE => self::BLOCK_NAME_TYPE, 90 | self::BLOCK_END_TYPE => self::BLOCK_END_TYPE, 91 | ]; 92 | 93 | public function __construct( 94 | private int|string $type, 95 | private int $line, 96 | private int $linePosition, 97 | private string $filename, 98 | private string $value = '', 99 | private ?self $relatedToken = null, 100 | ) { 101 | } 102 | 103 | public function getType(): int|string 104 | { 105 | return $this->type; 106 | } 107 | 108 | public function setType(int|string $type): void 109 | { 110 | $this->type = $type; 111 | } 112 | 113 | public function getLine(): int 114 | { 115 | return $this->line; 116 | } 117 | 118 | public function getLinePosition(): int 119 | { 120 | return $this->linePosition; 121 | } 122 | 123 | public function getFilename(): string 124 | { 125 | return $this->filename; 126 | } 127 | 128 | public function getValue(): string 129 | { 130 | return $this->value; 131 | } 132 | 133 | public function getRelatedToken(): ?self 134 | { 135 | return $this->relatedToken; 136 | } 137 | 138 | public function setRelatedToken(self $token): void 139 | { 140 | $this->relatedToken = $token; 141 | } 142 | 143 | /** 144 | * @param int|string|array $type 145 | * @param string|string[] $value 146 | */ 147 | public function isMatching(int|string|array $type, string|array $value = []): bool 148 | { 149 | if (!\is_array($type)) { 150 | $type = [$type]; 151 | } 152 | if (!\is_array($value)) { 153 | $value = [$value]; 154 | } 155 | 156 | return \in_array($this->getType(), $type, true) 157 | && ([] === $value || \in_array($this->getValue(), $value, true)); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Token/TokenizerInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private array $tokens = []; 15 | 16 | /** 17 | * @var int<0, max> 18 | */ 19 | private int $tokenCount = 0; 20 | 21 | /** 22 | * @var array 23 | */ 24 | private array $indexes = []; 25 | 26 | /** 27 | * @var list 28 | */ 29 | private array $ignoredViolations = []; 30 | 31 | private bool $readOnly = false; 32 | 33 | /** 34 | * @param array $tokens 35 | */ 36 | public function __construct(array $tokens = []) 37 | { 38 | foreach ($tokens as $token) { 39 | $this->add($token); 40 | } 41 | } 42 | 43 | public function setReadOnly(): self 44 | { 45 | $this->readOnly = true; 46 | 47 | return $this; 48 | } 49 | 50 | public function isReadOnly(): bool 51 | { 52 | return $this->readOnly; 53 | } 54 | 55 | public function add(Token $token): self 56 | { 57 | if ($this->readOnly) { 58 | throw new \LogicException('Cannot add token because the tokens are in read-only mode.'); 59 | } 60 | 61 | $this->tokens[] = $token; 62 | $this->indexes[spl_object_id($token)] = $this->tokenCount; 63 | ++$this->tokenCount; 64 | 65 | return $this; 66 | } 67 | 68 | public function get(int $index): Token 69 | { 70 | if (!$this->has($index)) { 71 | throw new \OutOfRangeException(\sprintf('There is no token for the index "%s".', $index)); 72 | } 73 | 74 | return $this->tokens[$index]; 75 | } 76 | 77 | public function getIndex(Token $token): int 78 | { 79 | $id = spl_object_id($token); 80 | if (!isset($this->indexes[$id])) { 81 | throw new \InvalidArgumentException('This token is not in the collection.'); 82 | } 83 | 84 | return $this->indexes[$id]; 85 | } 86 | 87 | public function has(int $index): bool 88 | { 89 | return isset($this->tokens[$index]); 90 | } 91 | 92 | /** 93 | * @return list 94 | */ 95 | public function toArray(): array 96 | { 97 | return $this->tokens; 98 | } 99 | 100 | /** 101 | * @param int|string|array $type 102 | */ 103 | public function findNext(int|string|array $type, int $start, ?int $end = null, bool $exclude = false): int|false 104 | { 105 | $end ??= $this->tokenCount; 106 | for ($i = $start; $i < $end; ++$i) { 107 | if ($exclude !== $this->get($i)->isMatching($type)) { 108 | return $i; 109 | } 110 | } 111 | 112 | return false; 113 | } 114 | 115 | /** 116 | * @param int|string|array $type 117 | */ 118 | public function findPrevious(int|string|array $type, int $start, int $end = 0, bool $exclude = false): int|false 119 | { 120 | for ($i = $start; $i >= $end; --$i) { 121 | if ($exclude !== $this->get($i)->isMatching($type)) { 122 | return $i; 123 | } 124 | } 125 | 126 | return false; 127 | } 128 | 129 | public function addIgnoredViolation(ViolationId $violationId): self 130 | { 131 | if ($this->readOnly) { 132 | throw new \LogicException('Cannot add ignored violation because the tokens are in read-only mode.'); 133 | } 134 | 135 | $this->ignoredViolations[] = $violationId; 136 | 137 | return $this; 138 | } 139 | 140 | /** 141 | * @return list 142 | */ 143 | public function getIgnoredViolations(): array 144 | { 145 | return $this->ignoredViolations; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Util/StringUtil.php: -------------------------------------------------------------------------------- 1 | replaceMatches('/[^\pL_0-9]++/u', '_') 19 | ->replaceMatches('/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '\1_\2') 20 | ->replaceMatches('/([\p{Ll}0-9])(\p{Lu})/u', '\1_\2') 21 | ->replaceMatches('/_+/', '_') 22 | ->trim('_') 23 | ->lower() 24 | ->toString(); 25 | } 26 | 27 | public static function toCamelCase(string $string): string 28 | { 29 | return (new UnicodeString($string))->camel()->toString(); 30 | } 31 | 32 | public static function toPascalCase(string $string): string 33 | { 34 | return ucfirst(static::toCamelCase($string)); 35 | } 36 | 37 | public static function toKebabCase(string $string): string 38 | { 39 | return str_replace('_', '-', self::toSnakeCase($string)); 40 | } 41 | } 42 | --------------------------------------------------------------------------------