Skip to content

Commit 071db5a

Browse files
authored
feat: exception for rules via @php-cs-fixer-ignore annotation (#9280)
1 parent d30627e commit 071db5a

File tree

7 files changed

+451
-44
lines changed

7 files changed

+451
-44
lines changed

doc/rules_exceptions.rst

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,51 @@ Sometimes you may want to ignore/modify certain rule for specific files or direc
2121
Sets provided by PHP CS Fixer are a living standards, and as such their definition is NOT covered with Backward Compatibility promise.
2222
That means any upgrade of PHP CS Fixer may add or remove fixers from the sets (or change their configuration).
2323
This already means that after upgrade of PHP CS Fixer, your project will start applying different rules, simply due to fact of upgrade.
24-
This may come from adding a new rules to the set, but also removed the rule or replace the deprecated rule by it's successor.
24+
This may come from adding a new rules to the set, but also removed the rule or replace the deprecated rule by its successor.
2525

2626
Now, when you use exceptions for the rules, this may lead to situation where, after PHP CS Fixer upgrade,
2727
your exception refers to a rule that is no longer part of the set you use.
2828

2929
For such cases, PHP CS Fixer will check that all the rules configured as exceptions are actually configured in set and raise error if some of them are not used.
3030
This will prevent accidental breaking of rules exceptions due to upgrade of PHP CS Fixer.
3131

32+
Configuring exceptions via ``@php-cs-fixer-ignore`` annotation
33+
--------------------------------------------------------------
34+
35+
This is the simplest way to **ignore** specific rule for specific file.
36+
37+
Just put this annotation in comment anywhere on top or bottom of the file, and the rule will be ignored for the whole file:
38+
39+
.. code-block:: php
40+
41+
<?php
42+
43+
declare(strict_types=1);
44+
45+
/*
46+
* File header..
47+
* LICENSE...
48+
*/
49+
50+
// @php-cs-fixer-ignore no_binary_string
51+
// @php-cs-fixer-ignore no_trailing_whitespace Optional comment - Rule ignored because of ...
52+
53+
/*
54+
* @php-cs-fixer-ignore no_unset_on_property,no_useless_else Multiple rules ignored at once
55+
*/
56+
57+
class MyClass {
58+
/* ... */
59+
}
60+
61+
// @php-cs-fixer-ignore no_empty_statement Works Also
62+
// @php-cs-fixer-ignore no_extra_blank_lines on bottom of file
63+
3264
Configuring exceptions via ``Rule Customisation Policy``
3365
--------------------------------------------------------
3466

67+
Sometimes, simple annotation usage for ignoring the rule in-file is not enough.
68+
3569
If you need to **ignore** or **reconfigure** a rule for specific files, you can inject ``RuleCustomisationPolicyInterface`` via ``Config::setRuleCustomisationPolicy()`` method:
3670

3771
.. code-block:: php

src/Runner/Runner.php

Lines changed: 97 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
use PhpCsFixer\Runner\Parallel\ProcessIdentifier;
4545
use PhpCsFixer\Runner\Parallel\ProcessPool;
4646
use PhpCsFixer\Runner\Parallel\WorkerException;
47+
use PhpCsFixer\Tokenizer\Analyzer\FixerAnnotationAnalyzer;
4748
use PhpCsFixer\Tokenizer\Tokens;
4849
use React\EventLoop\StreamSelectLoop;
4950
use React\Socket\ConnectionInterface;
@@ -100,6 +101,11 @@ final class Runner
100101
*/
101102
private array $fixers;
102103

104+
/**
105+
* @var array<non-empty-string, FixerInterface>
106+
*/
107+
private array $fixersByName;
108+
103109
private bool $stopOnViolation;
104110

105111
private ParallelConfig $parallelConfig;
@@ -136,6 +142,15 @@ public function __construct(
136142

137143
$this->fileIterator = $fileIterator;
138144
$this->fixers = $fixers;
145+
$this->fixersByName = array_reduce(
146+
$fixers,
147+
static function (array $carry, FixerInterface $fixer): array {
148+
$carry[$fixer->getName()] = $fixer;
149+
150+
return $carry;
151+
},
152+
[]
153+
);
139154
$this->differ = $differ;
140155
$this->eventDispatcher = $eventDispatcher;
141156
$this->errorsManager = $errorsManager;
@@ -169,52 +184,21 @@ public function setFileIterator(iterable $fileIterator): void
169184
*/
170185
public function fix(): array
171186
{
172-
$ruleCustomisers = $this->ruleCustomisationPolicy->getRuleCustomisers();
173-
if ([] !== $ruleCustomisers) {
174-
$usedFixerNames = array_map(
175-
static fn (FixerInterface $fixer): string => $fixer->getName(),
176-
$this->fixers
177-
);
178-
$missingFixerNames = array_diff(
179-
array_map(
180-
// key may be `int` if custom implementation of Policy doesn't fulfill the contract properly
181-
static fn (/* int|string */ $name): string => (string) $name, // @phpstan-ignore cast.useless
182-
array_keys($ruleCustomisers)
183-
),
184-
$usedFixerNames,
185-
);
186-
if ([] !== $missingFixerNames) {
187-
/** @TODO v3.999 check if rule is deprecated and show the replacement rules as well */
188-
$missingFixerNames = implode("\n- ", array_map(
189-
static function (string $name): string {
190-
$extra = '';
191-
if ('' === $name) { // @phpstan-ignore-line identical.alwaysFalse future-ready
192-
$extra = '(no name provided)';
193-
} elseif ('@' === $name[0]) {
194-
$extra = ' (can exclude only rules, not sets)';
195-
}
196-
// @TODO v3.999 handle "unknown rules"
197-
198-
return $name.$extra;
199-
},
200-
$missingFixerNames
201-
));
202-
203-
throw new \RuntimeException(
204-
<<<EOT
205-
Rule Customisation Policy contains customisers for fixers that are not in the current set of enabled fixers:
206-
- {$missingFixerNames}
207-
208-
Please check your configuration to ensure that these fixers are included, or update your Rule Customisation Policy if they have been replaced by other fixers in the version of PHP CS Fixer you are using.
209-
EOT
210-
);
211-
}
212-
}
213-
214187
if (0 === $this->fileCount) {
215188
return [];
216189
}
217190

191+
$ruleCustomisers = $this->ruleCustomisationPolicy->getRuleCustomisers();
192+
$this->validateRulesNamesForExceptions(
193+
array_keys($ruleCustomisers),
194+
<<<'EOT'
195+
Rule Customisation Policy contains customisers for rules that are not in the current set of enabled rules:
196+
%s
197+
198+
Please check your configuration to ensure that these rules are included, or update your Rule Customisation Policy if they have been replaced by other rules in the version of PHP CS Fixer you are using.
199+
EOT
200+
);
201+
218202
// @TODO 4.0: Remove condition and its body, as no longer needed when param will be required in the constructor.
219203
// This is a fallback only in case someone calls `new Runner()` in a custom repo and does not provide v4-ready params in v3-codebase.
220204
if (null === $this->input) {
@@ -231,6 +215,46 @@ static function (string $name): string {
231215
return $this->fixParallel();
232216
}
233217

218+
/**
219+
* @param list<string> $ruleExceptions
220+
* @param non-empty-string $errorTemplate
221+
*/
222+
private function validateRulesNamesForExceptions(array $ruleExceptions, string $errorTemplate): void
223+
{
224+
if ([] === $ruleExceptions) {
225+
return;
226+
}
227+
228+
$fixersByName = $this->fixersByName;
229+
$usedRules = array_keys($fixersByName);
230+
$missingRuleNames = array_diff($ruleExceptions, $usedRules);
231+
232+
if ([] === $missingRuleNames) {
233+
return;
234+
}
235+
236+
/** @TODO v3.999 check if rule is deprecated and show the replacement rules as well */
237+
$missingRulesDesc = implode("\n", array_map(
238+
static function (string $name) use ($fixersByName): string {
239+
$extra = '';
240+
if ('' === $name) {
241+
$extra = '(no name provided)';
242+
} elseif ('@' === $name[0]) {
243+
$extra = ' (can exclude only rules, not sets)';
244+
} elseif (!isset($fixersByName[$name])) {
245+
$extra = ' (unknown rule)';
246+
}
247+
248+
return '- '.$name.$extra;
249+
},
250+
$missingRuleNames
251+
));
252+
253+
throw new \RuntimeException(
254+
\sprintf($errorTemplate, $missingRulesDesc),
255+
);
256+
}
257+
234258
/**
235259
* Heavily inspired by {@see https://github.com/phpstan/phpstan-src/blob/9ce425bca5337039fb52c0acf96a20a2b8ace490/src/Parallel/ParallelAnalyser.php}.
236260
*
@@ -509,10 +533,39 @@ private function fixFile(\SplFileInfo $file, LintingResultInterface $lintingResu
509533

510534
$appliedFixers = [];
511535

512-
$ruleCustomisers = $this->ruleCustomisationPolicy->getRuleCustomisers();
536+
$ruleCustomisers = $this->ruleCustomisationPolicy->getRuleCustomisers(); // were already validated
537+
538+
try {
539+
$fixerAnnotationAnalysis = (new FixerAnnotationAnalyzer())->find($tokens);
540+
$rulesIgnoredByAnnotations = $fixerAnnotationAnalysis['php-cs-fixer-ignore'] ?? [];
541+
} catch (\RuntimeException $e) {
542+
throw new \RuntimeException(
543+
\sprintf(
544+
'Error while analysing file "%s": %s',
545+
$filePathname,
546+
$e->getMessage()
547+
),
548+
$e->getCode(),
549+
$e
550+
);
551+
}
552+
553+
$this->validateRulesNamesForExceptions(
554+
$rulesIgnoredByAnnotations,
555+
<<<EOT
556+
@php-cs-fixer-ignore annotation(s) used for rules that are not in the current set of enabled rules:
557+
%s
558+
559+
Please check your annotation(s) usage in {$filePathname} to ensure that these rules are included, or update your annotation(s) usage if they have been replaced by other rules in the version of PHP CS Fixer you are using.
560+
EOT
561+
);
513562

514563
try {
515564
foreach ($this->fixers as $fixer) {
565+
if (\in_array($fixer->getName(), $rulesIgnoredByAnnotations, true)) {
566+
continue;
567+
}
568+
516569
$customiser = $ruleCustomisers[$fixer->getName()] ?? null;
517570
if (null !== $customiser) {
518571
$actualFixer = $customiser($file);
@@ -530,6 +583,7 @@ private function fixFile(\SplFileInfo $file, LintingResultInterface $lintingResu
530583
$fixer = $actualFixer;
531584
}
532585
}
586+
533587
// for custom fixers we don't know is it safe to run `->fix()` without checking `->supports()` and `->isCandidate()`,
534588
// thus we need to check it and conditionally skip fixing
535589
if (
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of PHP CS Fixer.
7+
*
8+
* (c) Fabien Potencier <fabien@symfony.com>
9+
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
10+
*
11+
* This source file is subject to the MIT license that is bundled
12+
* with this source code in the file LICENSE.
13+
*/
14+
15+
namespace PhpCsFixer\Tokenizer\Analyzer;
16+
17+
use PhpCsFixer\Preg;
18+
use PhpCsFixer\Tokenizer\Tokens;
19+
20+
/**
21+
* Extracts @php-cs-fixer-* annotations from the given Tokens collection.
22+
*
23+
* Those annotations are controlling low-level PHP CS Fixer internal
24+
* are looked for only at the top and at the bottom of the file.
25+
* Any syntax of comment is allowed.
26+
*
27+
* @internal
28+
*
29+
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
30+
*
31+
* @no-named-arguments Parameter names are not covered by the backward compatibility promise.
32+
*/
33+
final class FixerAnnotationAnalyzer
34+
{
35+
/**
36+
* @return array<string, list<string>>
37+
*/
38+
public function find(Tokens $tokens): array
39+
{
40+
$comments = [];
41+
$annotations = [];
42+
43+
$count = $tokens->count();
44+
$index = 0;
45+
46+
for (0; $index < $count; ++$index) {
47+
$token = $tokens[$index];
48+
49+
if ($token->isGivenKind([
50+
\T_OPEN_TAG,
51+
\T_OPEN_TAG_WITH_ECHO,
52+
\T_WHITESPACE,
53+
]) || $token->equals(';')) {
54+
continue;
55+
}
56+
57+
if ($token->isGivenKind(\T_DECLARE)) {
58+
$nextIndex = $tokens->getNextMeaningfulToken($index);
59+
$index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $nextIndex);
60+
61+
continue;
62+
}
63+
64+
if ($token->isComment()) {
65+
$comments[] = $token->getContent();
66+
67+
continue;
68+
}
69+
70+
break;
71+
}
72+
73+
for ($indexBackwards = $count - 1; $indexBackwards > $index; --$indexBackwards) {
74+
$token = $tokens[$indexBackwards];
75+
76+
if ($token->isGivenKind([
77+
\T_CLOSE_TAG,
78+
\T_WHITESPACE,
79+
]) || $token->equals(';')) {
80+
continue;
81+
}
82+
83+
if ($token->isComment()) {
84+
$comments[] = $token->getContent();
85+
86+
continue;
87+
}
88+
89+
break;
90+
}
91+
92+
Preg::matchAll(
93+
'/^\h*[*\/]+\h+@(php-cs-fixer-\w+\h+(?:@?[\w,])+)/m',
94+
implode("\n", $comments),
95+
$matches
96+
);
97+
98+
foreach ($matches[1] as $match) {
99+
$matchParts = explode(' ', $match, 2);
100+
\assert(2 === \count($matchParts));
101+
102+
$annotations[$matchParts[0]] ??= [];
103+
array_push($annotations[$matchParts[0]], ...explode(',', $matchParts[1]));
104+
}
105+
106+
foreach ($annotations as $annotation => $vals) {
107+
$duplicates = array_keys(
108+
array_filter(
109+
array_count_values($vals),
110+
static fn (int $c): bool => $c > 1,
111+
)
112+
);
113+
114+
if (0 !== \count($duplicates)) {
115+
throw new \RuntimeException(\sprintf('Duplicated values found for annotation "@%s": "%s".', $annotation, implode('", "', $duplicates)));
116+
}
117+
118+
sort($vals);
119+
$annotations[$annotation] = $vals;
120+
}
121+
122+
return $annotations;
123+
}
124+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?php
2+
3+
assert(1111 > 0);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
assert(2222 > 0);
4+
5+
// @php-cs-fixer-ignore numeric_literal_separator

0 commit comments

Comments
 (0)