Skip to content

Commit 52ab69a

Browse files
authored
Merge pull request #277 from PHPCSStandards/universal/new-nodoublenegative-sniff
✨ New `Universal.CodeAnalysis.NoDoubleNegative` sniff
2 parents 8b52fe3 + 8ec8a17 commit 52ab69a

File tree

5 files changed

+587
-0
lines changed

5 files changed

+587
-0
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0"?>
2+
<documentation xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://phpcsstandards.github.io/PHPCSDevTools/phpcsdocs.xsd"
4+
title="No Double Negative"
5+
>
6+
<standard>
7+
<![CDATA[
8+
Detects double negation in code, which is effectively the same as a boolean cast, but with a much higher cognitive load.
9+
]]>
10+
</standard>
11+
<code_comparison>
12+
<code title="Valid: using singular negation or a boolean cast.">
13+
<![CDATA[
14+
$var = $a && <em>!</em> $b;
15+
16+
if(<em>(bool)</em> callMe($a)) {}
17+
]]>
18+
</code>
19+
<code title="Invalid: using double negation (or more).">
20+
<![CDATA[
21+
$var = $a && <em>! !</em> $b;
22+
23+
if(<em>! ! !</em> callMe($a)) {}
24+
]]>
25+
</code>
26+
</code_comparison>
27+
</documentation>
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
<?php
2+
/**
3+
* PHPCSExtra, a collection of sniffs and standards for use with PHP_CodeSniffer.
4+
*
5+
* @package PHPCSExtra
6+
* @copyright 2023 PHPCSExtra Contributors
7+
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
8+
* @link https://github.com/PHPCSStandards/PHPCSExtra
9+
*/
10+
11+
namespace PHPCSExtra\Universal\Sniffs\CodeAnalysis;
12+
13+
use PHP_CodeSniffer\Files\File;
14+
use PHP_CodeSniffer\Sniffs\Sniff;
15+
use PHP_CodeSniffer\Util\Tokens;
16+
use PHPCSUtils\BackCompat\BCFile;
17+
use PHPCSUtils\Utils\GetTokensAsString;
18+
use PHPCSUtils\Utils\Parentheses;
19+
20+
/**
21+
* Detects double negation in code, which is effectively the same as a boolean cast,
22+
* but with a much higher cognitive load.
23+
*
24+
* The sniff will only autofix if the precedence change from boolean not to boolean cast
25+
* will not cause a behavioural change (as it would with instanceof).
26+
*
27+
* @since 1.2.0
28+
*/
29+
final class NoDoubleNegativeSniff implements Sniff
30+
{
31+
32+
/**
33+
* Operators with lower precedence than the not-operator.
34+
*
35+
* Used to determine when to stop searching for `instanceof`.
36+
*
37+
* @since 1.2.0
38+
*
39+
* @var array<int|string, int|string>
40+
*/
41+
private $operatorsWithLowerPrecedence;
42+
43+
/**
44+
* Returns an array of tokens this test wants to listen for.
45+
*
46+
* @since 1.2.0
47+
*
48+
* @return array<int|string>
49+
*/
50+
public function register()
51+
{
52+
// Collect all the operators only once.
53+
$this->operatorsWithLowerPrecedence = Tokens::$assignmentTokens;
54+
$this->operatorsWithLowerPrecedence += Tokens::$booleanOperators;
55+
$this->operatorsWithLowerPrecedence += Tokens::$comparisonTokens;
56+
$this->operatorsWithLowerPrecedence += Tokens::$operators;
57+
$this->operatorsWithLowerPrecedence[\T_INLINE_THEN] = \T_INLINE_THEN;
58+
$this->operatorsWithLowerPrecedence[\T_INLINE_ELSE] = \T_INLINE_ELSE;
59+
60+
return [\T_BOOLEAN_NOT];
61+
}
62+
63+
/**
64+
* Processes this test, when one of its tokens is encountered.
65+
*
66+
* @since 1.2.0
67+
*
68+
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
69+
* @param int $stackPtr The position of the current token
70+
* in the stack passed in $tokens.
71+
*
72+
* @return int|void Integer stack pointer to skip forward or void to continue
73+
* normal file processing.
74+
*/
75+
public function process(File $phpcsFile, $stackPtr)
76+
{
77+
$tokens = $phpcsFile->getTokens();
78+
79+
$notCount = 1;
80+
$lastNot = $stackPtr;
81+
for ($afterNot = ($stackPtr + 1); $afterNot < $phpcsFile->numTokens; $afterNot++) {
82+
if (isset(Tokens::$emptyTokens[$tokens[$afterNot]['code']])) {
83+
continue;
84+
}
85+
86+
if ($tokens[$afterNot]['code'] === \T_BOOLEAN_NOT) {
87+
$lastNot = $afterNot;
88+
++$notCount;
89+
continue;
90+
}
91+
92+
break;
93+
}
94+
95+
if ($notCount === 1) {
96+
// Singular unary not-operator. Nothing to do.
97+
return;
98+
}
99+
100+
$found = \trim(GetTokensAsString::compact($phpcsFile, $stackPtr, $lastNot));
101+
$data = [$found];
102+
103+
if (($notCount % 2) === 1) {
104+
/*
105+
* Oh dear... silly code time, found a triple negative (or other uneven number),
106+
* this should just be a singular not-operator.
107+
*/
108+
$fix = $phpcsFile->addFixableError(
109+
'Triple negative (or more) detected. Use a singular not (!) operator instead. Found: %s',
110+
$stackPtr,
111+
'FoundTriple',
112+
$data
113+
);
114+
115+
if ($fix === true) {
116+
$phpcsFile->fixer->beginChangeset();
117+
118+
$this->removeNotAndTrailingSpaces($phpcsFile, $stackPtr, $lastNot);
119+
120+
$phpcsFile->fixer->endChangeset();
121+
}
122+
123+
// Only throw one error, even if there are more than two not-operators.
124+
return $lastNot;
125+
}
126+
127+
/*
128+
* Found a double negative, which should be a boolean cast.
129+
*/
130+
131+
$fixable = true;
132+
133+
/*
134+
* If whatever is being "cast" is within parentheses, we're good.
135+
* If not, we need to prevent creating a change in behaviour
136+
* when what follows is an `$x instanceof ...` expression, as
137+
* the "instanceof" operator is right between a boolean cast
138+
* and the ! operator precedence-wise.
139+
*
140+
* Note: this only applies to double negative, not triple negative.
141+
*
142+
* @link https://www.php.net/language.operators.precedence
143+
*/
144+
if ($tokens[$afterNot]['code'] !== \T_OPEN_PARENTHESIS) {
145+
$end = Parentheses::getLastCloser($phpcsFile, $stackPtr);
146+
if ($end === false) {
147+
$end = BCFile::findEndOfStatement($phpcsFile, $stackPtr);
148+
}
149+
150+
for ($nextRelevant = $afterNot; $nextRelevant < $end; $nextRelevant++) {
151+
if (isset(Tokens::$emptyTokens[$tokens[$nextRelevant]['code']])) {
152+
continue;
153+
}
154+
155+
if ($tokens[$nextRelevant]['code'] === \T_INSTANCEOF) {
156+
$fixable = false;
157+
break;
158+
}
159+
160+
if (isset($this->operatorsWithLowerPrecedence[$tokens[$nextRelevant]['code']])) {
161+
// The expression the `!` belongs to has ended.
162+
break;
163+
}
164+
165+
// Skip over anything within some form of brackets.
166+
if (isset($tokens[$nextRelevant]['scope_closer'])
167+
&& ($nextRelevant === $tokens[$nextRelevant]['scope_opener']
168+
|| $nextRelevant === $tokens[$nextRelevant]['scope_condition'])
169+
) {
170+
$nextRelevant = $tokens[$nextRelevant]['scope_closer'];
171+
continue;
172+
}
173+
174+
if (isset($tokens[$nextRelevant]['bracket_opener'], $tokens[$nextRelevant]['bracket_closer'])
175+
&& $nextRelevant === $tokens[$nextRelevant]['bracket_opener']
176+
) {
177+
$nextRelevant = $tokens[$nextRelevant]['bracket_closer'];
178+
continue;
179+
}
180+
181+
if ($tokens[$nextRelevant]['code'] === \T_OPEN_PARENTHESIS
182+
&& isset($tokens[$nextRelevant]['parenthesis_closer'])
183+
) {
184+
$nextRelevant = $tokens[$nextRelevant]['parenthesis_closer'];
185+
continue;
186+
}
187+
188+
// Skip over attributes (just in case).
189+
if ($tokens[$nextRelevant]['code'] === \T_ATTRIBUTE
190+
&& isset($tokens[$nextRelevant]['attribute_closer'])
191+
) {
192+
$nextRelevant = $tokens[$nextRelevant]['attribute_closer'];
193+
continue;
194+
}
195+
}
196+
}
197+
198+
$error = 'Double negative detected. Use a (bool) cast %s instead. Found: %s';
199+
$code = 'FoundDouble';
200+
$data = [
201+
'',
202+
$found,
203+
];
204+
205+
if ($fixable === false) {
206+
$code = 'FoundDoubleWithInstanceof';
207+
$data[0] = 'and parentheses around the instanceof expression';
208+
209+
// Don't auto-fix in combination with instanceof.
210+
$phpcsFile->addError($error, $stackPtr, $code, $data);
211+
212+
// Only throw one error, even if there are more than two not-operators.
213+
return $lastNot;
214+
}
215+
216+
$fix = $phpcsFile->addFixableError($error, $stackPtr, $code, $data);
217+
218+
if ($fix === true) {
219+
$phpcsFile->fixer->beginChangeset();
220+
221+
$this->removeNotAndTrailingSpaces($phpcsFile, $stackPtr, $lastNot);
222+
223+
$phpcsFile->fixer->replaceToken($lastNot, '(bool)');
224+
225+
$phpcsFile->fixer->endChangeset();
226+
}
227+
228+
// Only throw one error, even if there are more than two not-operators.
229+
return $lastNot;
230+
}
231+
232+
/**
233+
* Remove boolean not-operators and trailing whitespace after those,
234+
* but don't remove comments or trailing whitespace after comments.
235+
*
236+
* @since 1.2.0
237+
*
238+
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
239+
* @param int $stackPtr The position of the current token
240+
* in the stack passed in $tokens.
241+
* @param int $lastNot The position of the last boolean not token
242+
* in the chain.
243+
*
244+
* @return void
245+
*/
246+
private function removeNotAndTrailingSpaces(File $phpcsFile, $stackPtr, $lastNot)
247+
{
248+
$tokens = $phpcsFile->getTokens();
249+
$ignore = false;
250+
251+
for ($i = $stackPtr; $i < $lastNot; $i++) {
252+
if (isset(Tokens::$commentTokens[$tokens[$i]['code']])) {
253+
// Ignore comments and whitespace after comments.
254+
$ignore = true;
255+
continue;
256+
}
257+
258+
if ($tokens[$i]['code'] === \T_WHITESPACE && $ignore === false) {
259+
$phpcsFile->fixer->replaceToken($i, '');
260+
continue;
261+
}
262+
263+
if ($tokens[$i]['code'] === \T_BOOLEAN_NOT) {
264+
$ignore = false;
265+
$phpcsFile->fixer->replaceToken($i, '');
266+
}
267+
}
268+
}
269+
}

0 commit comments

Comments
 (0)