Skip to content

Commit

Permalink
New Operators::isNullsafeObjectOperator() method
Browse files Browse the repository at this point in the history
... which emulates the backfill as pulled to PHPCS itself.

PHP 8 introduces a new object chaining operator `?->` which short-circuits moving to the next expression if the left-hand side evaluates to `null`.

This operator can not be used in write-context, but that is not the concern of this method.

For PHPCS versions where the backfill is not available yet, this method can be used to determine whether a `?` or `->` token is actually part of the `?->` nullsafe object operator token.

Includes perfunctory unit tests.
Includes mentioning the method in appropriate places in the documentation elsewhere.

Refs:
* squizlabs/PHP_CodeSniffer 3046
* https://wiki.php.net/rfc/nullsafe_operator
* php/php-src@9bf1198
  • Loading branch information
jrfnl committed Aug 7, 2020
1 parent c816e8e commit 50fc1d1
Show file tree
Hide file tree
Showing 4 changed files with 252 additions and 0 deletions.
13 changes: 13 additions & 0 deletions PHPCSUtils/Tokens/Collections.php
Original file line number Diff line number Diff line change
Expand Up @@ -633,9 +633,15 @@ public static function objectOperators()
* 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
*
Expand All @@ -660,6 +666,13 @@ public static function objectOperatorsBC()
*
* 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 <int|string> => <int|string>
Expand Down
50 changes: 50 additions & 0 deletions PHPCSUtils/Utils/Operators.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
33 changes: 33 additions & 0 deletions Tests/Utils/Operators/IsNullsafeObjectOperatorTest.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* Null safe operator.
*/

/* testUnsupportedToken */
echo $obj::foo;

/* testObjectOperator */
echo $obj->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?

156 changes: 156 additions & 0 deletions Tests/Utils/Operators/IsNullsafeObjectOperatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php
/**
* PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
*
* @package PHPCSUtils
* @copyright 2019-2020 PHPCSUtils Contributors
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
* @link https://github.com/PHPCSStandards/PHPCSUtils
*/

namespace PHPCSUtils\Tests\Utils\Operators;

use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\TestUtils\UtilityMethodTestCase;
use PHPCSUtils\Utils\Operators;

/**
* Tests for the \PHPCSUtils\Utils\Operators::isNullsafeObjectOperator() method.
*
* @covers \PHPCSUtils\Utils\Operators::isNullsafeObjectOperator
*
* @group operators
*
* @since 1.0.0
*/
class IsNullsafeObjectOperatorTest extends UtilityMethodTestCase
{

/**
* Test that false is returned when a non-existent token is passed.
*
* @return void
*/
public function testNonExistentToken()
{
$this->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 <int|string> => <int|string>
*/
private function getTargetTokensTypes()
{
$targets = [
\T_OBJECT_OPERATOR,
\T_INLINE_THEN,
];

if (defined('T_NULLSAFE_OBJECT_OPERATOR') === true) {
$targets[] = \T_NULLSAFE_OBJECT_OPERATOR;
}

return $targets;
}
}

0 comments on commit 50fc1d1

Please sign in to comment.