diff --git a/PHPCSUtils/Tokens/Collections.php b/PHPCSUtils/Tokens/Collections.php index dc2fcb19..c843dfe4 100644 --- a/PHPCSUtils/Tokens/Collections.php +++ b/PHPCSUtils/Tokens/Collections.php @@ -231,10 +231,13 @@ class Collections ]; /** - * Object operators. + * DEPRECATED: Object operators. * * @since 1.0.0-alpha3 * + * @deprecated 1.0.0-alpha4 Use the {@see \PHPCSUtils\Tokens\Collections::objectOperators()} + * method instead. + * * @var array => */ public static $objectOperators = [ @@ -576,6 +579,119 @@ public static function functionDeclarationTokensBC() return $tokens; } + /** + * Object operators. + * + * Note: this is a method, not a property as the `T_NULLSAFE_OBJECT_OPERATOR` token may not exist. + * + * Sister-method to the {@see Collections::objectOperatorsBC()} method. + * This method supports PHP 8.0 and up. + * The {@see Collections::objectOperatorsBC()} method supports PHP < 8.0. + * + * This method can also safely be used if the token collection is only used when looking back + * via `$phpcsFile->findPrevious()` as in that case, a non-backfilled nullsafe object operator + * will still match the "normal" object operator. + * + * @see \PHPCSUtils\Tokens\Collections::objectOperatorsBC() Related method (PHP < 8.0). + * + * @since 1.0.0-alpha4 + * + * @return array => + */ + public static function objectOperators() + { + $tokens = [ + \T_OBJECT_OPERATOR => \T_OBJECT_OPERATOR, + \T_DOUBLE_COLON => \T_DOUBLE_COLON, + ]; + + if (\defined('T_NULLSAFE_OBJECT_OPERATOR') === true) { + // PHP 8.0. + $tokens[\T_NULLSAFE_OBJECT_OPERATOR] = \T_NULLSAFE_OBJECT_OPERATOR; + } + + return $tokens; + } + + /** + * Object operators. + * + * Note: this is a method, not a property as the `T_NULLSAFE_OBJECT_OPERATOR` token may not exist. + * + * Sister-method to the {@see Collections::objectOperators()} method. + * The {@see Collections::objectOperators()} method supports PHP 8.0 and up. + * This method supports PHP < 8.0. + * + * Notable difference: + * - This method accounts for tokens which may be encountered when the `T_NULLSAFE_OBJECT_OPERATOR` token + * doesn't exist. + * + * It is recommended to use the {@see Collections::objectOperators()} method instead of + * this method if a standard does not need to support PHP < 8.0. + * + * The {@see Collections::objectOperators()} method can also safely be used if the token collection + * is only used when looking back via `$phpcsFile->findPrevious()` as in that case, a non-backfilled + * nullsafe object operator will still match the "normal" object operator. + * + * Note: if this method is used, the {@see \PHPCSUtils\Utils\Operators::isNullsafeObjectOperator()} + * method needs to be used on potential nullsafe object operator tokens to verify whether it really + * is a nullsafe object operator or not. + * + * @see \PHPCSUtils\Tokens\Collections::objectOperators() Related method (PHP 8.0+). + * @see \PHPCSUtils\Tokens\Collections::nullsafeObjectOperatorBC() Tokens which can represent a + * nullsafe object operator. + * @see \PHPCSUtils\Utils\Operators::isNullsafeObjectOperator() Nullsafe object operator detection for + * PHP < 8.0. + * + * @since 1.0.0-alpha4 + * + * @return array => + */ + public static function objectOperatorsBC() + { + $tokens = [ + \T_OBJECT_OPERATOR => \T_OBJECT_OPERATOR, + \T_DOUBLE_COLON => \T_DOUBLE_COLON, + ]; + + $tokens += self::nullsafeObjectOperatorBC(); + + return $tokens; + } + + /** + * Tokens which can represent the nullsafe object operator. + * + * This method will return the appropriate tokens based on the PHP/PHPCS version used. + * + * Note: this is a method, not a property as the `T_NULLSAFE_OBJECT_OPERATOR` token may not exist. + * + * Note: if this method is used, the {@see \PHPCSUtils\Utils\Operators::isNullsafeObjectOperator()} + * method needs to be used on potential nullsafe object operator tokens to verify whether it really + * is a nullsafe object operator or not. + * + * @see \PHPCSUtils\Utils\Operators::isNullsafeObjectOperator() Nullsafe object operator detection for + * PHP < 8.0. + * + * @since 1.0.0-alpha4 + * + * @return array => + */ + public static function nullsafeObjectOperatorBC() + { + if (\defined('T_NULLSAFE_OBJECT_OPERATOR') === true) { + // PHP 8.0. + return [ + \T_NULLSAFE_OBJECT_OPERATOR => \T_NULLSAFE_OBJECT_OPERATOR, + ]; + } + + return [ + \T_INLINE_THEN => \T_INLINE_THEN, + \T_OBJECT_OPERATOR => \T_OBJECT_OPERATOR, + ]; + } + /** * Token types which can be encountered in a parameter type declaration. * diff --git a/PHPCSUtils/Utils/Namespaces.php b/PHPCSUtils/Utils/Namespaces.php index 47458ce2..fa01b6bc 100644 --- a/PHPCSUtils/Utils/Namespaces.php +++ b/PHPCSUtils/Utils/Namespaces.php @@ -62,7 +62,7 @@ public static function getType(File $phpcsFile, $stackPtr) + Tokens::$castTokens + Tokens::$blockOpeners + Collections::$incrementDecrementOperators - + Collections::$objectOperators; + + Collections::objectOperators(); $findAfter[\T_OPEN_CURLY_BRACKET] = \T_OPEN_CURLY_BRACKET; $findAfter[\T_OPEN_SQUARE_BRACKET] = \T_OPEN_SQUARE_BRACKET; diff --git a/PHPCSUtils/Utils/Operators.php b/PHPCSUtils/Utils/Operators.php index f55129ee..0672737e 100644 --- a/PHPCSUtils/Utils/Operators.php +++ b/PHPCSUtils/Utils/Operators.php @@ -311,6 +311,56 @@ public static function isTypeUnion(File $phpcsFile, $stackPtr) return false; } + /** + * Determine whether a token is (part of) a nullsafe object operator. + * + * Helper method for PHP < 8.0 in combination with PHPCS versions in which the + * `T_NULLSAFE_OBJECT_OPERATOR` token is not yet backfilled. + * + * @since 1.0.0-alpha4 + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. + * @param int $stackPtr The position of the T_INLINE_THEN or T_OBJECT_OPERATOR + * token in the stack. + * + * @return bool `TRUE` if nullsafe object operator; or `FALSE` otherwise. + */ + public static function isNullsafeObjectOperator(File $phpcsFile, $stackPtr) + { + $tokens = $phpcsFile->getTokens(); + if (isset($tokens[$stackPtr]) === false) { + return false; + } + + // Safeguard in case this method is used on PHP 8 and the nullsafe object operator would be passed. + if ($tokens[$stackPtr]['type'] === 'T_NULLSAFE_OBJECT_OPERATOR') { + return true; + } + + if (isset(Collections::nullsafeObjectOperatorBC()[$tokens[$stackPtr]['code']]) === false) { + return false; + } + + /* + * Note: not bypassing empty tokens as whitespace and comments are not allowed + * within an operator. + */ + if ($tokens[$stackPtr]['code'] === \T_INLINE_THEN) { + if (isset($tokens[$stackPtr + 1]) && $tokens[$stackPtr + 1]['code'] === \T_OBJECT_OPERATOR) { + return true; + } + } + + if ($tokens[$stackPtr]['code'] === \T_OBJECT_OPERATOR) { + if (isset($tokens[$stackPtr - 1]) && $tokens[$stackPtr - 1]['code'] === \T_INLINE_THEN) { + return true; + } + } + + // Not a nullsafe object operator token. + return false; + } + /** * Determine whether a T_MINUS/T_PLUS token is a unary operator. * diff --git a/Tests/Tokens/Collections/NullsafeObjectOperatorTokensBCTest.php b/Tests/Tokens/Collections/NullsafeObjectOperatorTokensBCTest.php new file mode 100644 index 00000000..80bfc755 --- /dev/null +++ b/Tests/Tokens/Collections/NullsafeObjectOperatorTokensBCTest.php @@ -0,0 +1,50 @@ +=') === true + ) { + $expected = [ + \T_NULLSAFE_OBJECT_OPERATOR => \T_NULLSAFE_OBJECT_OPERATOR, + ]; + } else { + $expected = [ + \T_INLINE_THEN => \T_INLINE_THEN, + \T_OBJECT_OPERATOR => \T_OBJECT_OPERATOR, + ]; + } + + $this->assertSame($expected, Collections::nullsafeObjectOperatorBC()); + } +} diff --git a/Tests/Tokens/Collections/ObjectOperatorsBCTest.php b/Tests/Tokens/Collections/ObjectOperatorsBCTest.php new file mode 100644 index 00000000..f8204099 --- /dev/null +++ b/Tests/Tokens/Collections/ObjectOperatorsBCTest.php @@ -0,0 +1,49 @@ + \T_OBJECT_OPERATOR, + \T_DOUBLE_COLON => \T_DOUBLE_COLON, + ]; + + if (\version_compare(\PHP_VERSION_ID, '80000', '>=') === true + ) { + $expected[\T_NULLSAFE_OBJECT_OPERATOR] = \T_NULLSAFE_OBJECT_OPERATOR; + } else { + $expected[\T_INLINE_THEN] = \T_INLINE_THEN; + } + + $this->assertSame($expected, Collections::objectOperatorsBC()); + } +} diff --git a/Tests/Tokens/Collections/ObjectOperatorsTest.php b/Tests/Tokens/Collections/ObjectOperatorsTest.php new file mode 100644 index 00000000..a6f8c591 --- /dev/null +++ b/Tests/Tokens/Collections/ObjectOperatorsTest.php @@ -0,0 +1,47 @@ + \T_OBJECT_OPERATOR, + \T_DOUBLE_COLON => \T_DOUBLE_COLON, + ]; + + if (\version_compare(\PHP_VERSION_ID, '80000', '>=') === true + ) { + $expected[\T_NULLSAFE_OBJECT_OPERATOR] = \T_NULLSAFE_OBJECT_OPERATOR; + } + + $this->assertSame($expected, Collections::objectOperators()); + } +} diff --git a/Tests/Utils/Namespaces/NamespaceTypeTest.inc b/Tests/Utils/Namespaces/NamespaceTypeTest.inc index 99e13c1a..7b9c31ab 100644 --- a/Tests/Utils/Namespaces/NamespaceTypeTest.inc +++ b/Tests/Utils/Namespaces/NamespaceTypeTest.inc @@ -45,6 +45,9 @@ namespace\ClassName::$property++; /* testNamespaceOperatorGlobalNamespaceStartOfStatementCombiWithNonConfusingToken3 */ namespace\CONSTANT['key']; +/* testNamespaceOperatorGlobalNamespaceStartOfStatementCombiWithNonConfusingToken4 */ +namespace\functionReturningObj()?->chained(); + /* testParseErrorScopedNamespaceDeclaration */ function testScope() { diff --git a/Tests/Utils/Namespaces/NamespaceTypeTest.php b/Tests/Utils/Namespaces/NamespaceTypeTest.php index 5eb39394..5be9717a 100644 --- a/Tests/Utils/Namespaces/NamespaceTypeTest.php +++ b/Tests/Utils/Namespaces/NamespaceTypeTest.php @@ -184,6 +184,13 @@ public function dataNamespaceType() 'operator' => true, ], ], + 'namespace-operator-global-namespace-start-of-statement-with-non-confusing-token-4' => [ + '/* testNamespaceOperatorGlobalNamespaceStartOfStatementCombiWithNonConfusingToken4 */', + [ + 'declaration' => false, + 'operator' => true, + ], + ], 'parse-error-scoped-namespace-declaration' => [ '/* testParseErrorScopedNamespaceDeclaration */', [ diff --git a/Tests/Utils/Operators/IsNullsafeObjectOperatorTest.inc b/Tests/Utils/Operators/IsNullsafeObjectOperatorTest.inc new file mode 100644 index 00000000..aa7c34ff --- /dev/null +++ b/Tests/Utils/Operators/IsNullsafeObjectOperatorTest.inc @@ -0,0 +1,33 @@ +foo; + +/* testNullsafeObjectOperator */ +echo $obj?->foo; + +/* testNullsafeObjectOperatorWriteContext */ +// Intentional parse error, but not the concern of this method. +$foo?->bar->baz = 'baz'; + +/* testTernaryThen */ +echo $obj ? $obj->prop : /* testObjectOperatorInTernary */ $other->prop; + +/* testParseErrorWhitespaceNotAllowed */ +echo $obj ? + -> foo; + +/* testParseErrorCommentNotAllowed */ +echo $obj ?/*comment*/-> foo; + +/* testLiveCoding */ +// Intentional parse error. This has to be the last test in the file. +echo $obj? + diff --git a/Tests/Utils/Operators/IsNullsafeObjectOperatorTest.php b/Tests/Utils/Operators/IsNullsafeObjectOperatorTest.php new file mode 100644 index 00000000..fff86478 --- /dev/null +++ b/Tests/Utils/Operators/IsNullsafeObjectOperatorTest.php @@ -0,0 +1,156 @@ +assertFalse(Operators::isNullsafeObjectOperator(self::$phpcsFile, 10000)); + } + + /** + * Test that false is returned when an unsupported token is passed. + * + * @return void + */ + public function testUnsupportedToken() + { + $target = $this->getTargetToken('/* testUnsupportedToken */', \T_DOUBLE_COLON); + $this->assertFalse(Operators::isNullsafeObjectOperator(self::$phpcsFile, $target)); + } + + /** + * Test whether a nullsafe object operator is correctly identified as such. + * + * @dataProvider dataIsNullsafeObjectOperator + * + * @param string $testMarker The comment which prefaces the target token in the test file. + * + * @return void + */ + public function testIsNullsafeObjectOperator($testMarker) + { + $targetTokenTypes = $this->getTargetTokensTypes(); + $stackPtr = $this->getTargetToken($testMarker, $targetTokenTypes); + + $this->assertTrue( + Operators::isNullsafeObjectOperator(self::$phpcsFile, $stackPtr), + 'Failed asserting that (first) token is the nullsafe object operator' + ); + + // Also test the second token of a non-backfilled nullsafe object operator. + $tokens = self::$phpcsFile->getTokens(); + if ($tokens[$stackPtr]['code'] === \T_INLINE_THEN) { + $stackPtr = $this->getTargetToken($testMarker, [\T_OBJECT_OPERATOR]); + + $this->assertTrue( + Operators::isNullsafeObjectOperator(self::$phpcsFile, $stackPtr), + 'Failed asserting that (second) token is the nullsafe object operator' + ); + } + } + + /** + * Data provider. + * + * @see testIsNullsafeObjectOperator() + * + * @return array + */ + public function dataIsNullsafeObjectOperator() + { + return [ + 'nullsafe' => ['/* testNullsafeObjectOperator */'], + 'nullsafe-write-context' => ['/* testNullsafeObjectOperatorWriteContext */'], + ]; + } + + /** + * Test whether tokens which can be confused with a non-nullsafe object operator are + * not misidentified as a nullsafe object operator. + * + * @dataProvider dataNotNullsafeObjectOperator + * + * @param string $testMarker The comment which prefaces the target token in the test file. + * @param bool $textNext Whether to also test the next non-empty token. Defaults to false. + * + * @return void + */ + public function testNotNullsafeObjectOperator($testMarker, $textNext = false) + { + $stackPtr = $this->getTargetToken($testMarker, $this->getTargetTokensTypes()); + + $this->assertFalse(Operators::isNullsafeObjectOperator(self::$phpcsFile, $stackPtr)); + + if ($textNext === true) { + $next = self::$phpcsFile->findNext(Tokens::$emptyTokens, ($stackPtr + 1), null, true); + $this->assertFalse(Operators::isNullsafeObjectOperator(self::$phpcsFile, $next)); + } + } + + /** + * Data provider. + * + * @see testNotNullsafeObjectOperator() + * + * @return array + */ + public function dataNotNullsafeObjectOperator() + { + return [ + 'normal-object-operator' => ['/* testObjectOperator */'], + 'ternary-then' => ['/* testTernaryThen */'], + 'object-operator-in-ternary' => ['/* testObjectOperatorInTernary */'], + 'parse-error-whitespace-not-allowed' => ['/* testParseErrorWhitespaceNotAllowed */', true], + 'parse-error-comment-not-allowed' => ['/* testParseErrorCommentNotAllowed */', true], + 'live-coding' => ['/* testLiveCoding */'], + ]; + } + + /** + * Get the target token types to pass to the getTargetToken() method. + * + * @return array => + */ + private function getTargetTokensTypes() + { + $targets = [ + \T_OBJECT_OPERATOR, + \T_INLINE_THEN, + ]; + + if (defined('T_NULLSAFE_OBJECT_OPERATOR') === true) { + $targets[] = \T_NULLSAFE_OBJECT_OPERATOR; + } + + return $targets; + } +}