From 1b4153a0b547ff0cc934b470ed644a855580af1f Mon Sep 17 00:00:00 2001 From: jrfnl Date: Mon, 22 Sep 2025 13:59:53 +0200 Subject: [PATCH] :sparkles: New `PHPCSUtils\Utils\AttributeBlock` class ... to contain utility methods for analysing attribute blocks, where an attribute block is defined as being an attribute opener, an attribute closer and everything between. I.e. `#[MyAttribute(1, 2), AnotherAttribute]` is one attribute block. Initially, the class comes with the following methods: * `getAttributes(File $phpcsFile, $stackPtr): array` to retrieve an array of information about each attribute referenced in an attribute block. The returned array will contain the following information on each attribute in the block: - `name` (`string`): the complete name of the attribute as found in the attribute block. - `name_token` (`int`): stack pointer to the last token in the name (which can be passed to the methods in the `PassedParameters` class to retrieve any passed arguments). - `start` (`int`): the stack pointer to the first token in the attribute instantiation. Mind: this may be a whitespace (or comment token), like for `AnotherAttribute` in the example above. - `end` (`int`): the stack pointer to the last token in the attribute instantiation. Again, mind: this may be a whitespace/comment token. - `comma_token` (`int|false`): the stack pointer to the comma after the attribute instantiation or `false` if this is the last attribute and there is no comma. * `countAttributes(File $phpcsFile, $stackPtr): int`: convenience method to count the number of attribute instantiations in an attribute block. These methods expect to be passed a `T_ATTRIBUTE` (attribute block opener) token as the `$stackPtr`. Includes extensive unit tests. Related to #616 --- PHPCSUtils/Utils/AttributeBlock.php | 178 ++++++++ .../GetAttributesParseError1Test.inc | 7 + .../GetAttributesParseError1Test.php | 40 ++ .../GetAttributesParseError2Test.inc | 7 + .../GetAttributesParseError2Test.php | 40 ++ .../GetAttributesParseError3Test.inc | 7 + .../GetAttributesParseError3Test.php | 40 ++ .../AttributeBlock/GetAttributesTest.inc | 65 +++ .../AttributeBlock/GetAttributesTest.php | 428 ++++++++++++++++++ 9 files changed, 812 insertions(+) create mode 100644 PHPCSUtils/Utils/AttributeBlock.php create mode 100644 Tests/Utils/AttributeBlock/GetAttributesParseError1Test.inc create mode 100644 Tests/Utils/AttributeBlock/GetAttributesParseError1Test.php create mode 100644 Tests/Utils/AttributeBlock/GetAttributesParseError2Test.inc create mode 100644 Tests/Utils/AttributeBlock/GetAttributesParseError2Test.php create mode 100644 Tests/Utils/AttributeBlock/GetAttributesParseError3Test.inc create mode 100644 Tests/Utils/AttributeBlock/GetAttributesParseError3Test.php create mode 100644 Tests/Utils/AttributeBlock/GetAttributesTest.inc create mode 100644 Tests/Utils/AttributeBlock/GetAttributesTest.php diff --git a/PHPCSUtils/Utils/AttributeBlock.php b/PHPCSUtils/Utils/AttributeBlock.php new file mode 100644 index 00000000..f461efab --- /dev/null +++ b/PHPCSUtils/Utils/AttributeBlock.php @@ -0,0 +1,178 @@ +> + * A multi-dimentional array with information on each attribute instantiation in the block. + * The information gathered about each attribute instantiation is in the following format: + * ```php + * array( + * 'name' => string, // The full name of the attribute being instantiated. + * // This will be name as passed without namespace resolution. + * 'name_token' => int, // The stack pointer to the last token in the attribute name. + * // Pro-tip: this token can be passed on to the methods in the + * // {@see PassedParameters} class to retrieve the + * // parameters passed to the attribute constructor. + * 'start' => int, // The stack pointer to the first token in the attribute instantiation. + * // Note: this may be a leading whitespace/comment token. + * 'end' => int, // The stack pointer to the last token in the attribute instantiation. + * // Note: this may be a trailing whitespace/comment token. + * 'comma_token' => int|false, // The stack pointer to the comma after the attribute instantiation + * // or FALSE if this is the last attribute and there is no comma. + * ) + * ``` + * If no attributes are found, an empty array will be returned. + * + * @throws \PHPCSUtils\Exceptions\TypeError If the $stackPtr parameter is not an integer. + * @throws \PHPCSUtils\Exceptions\OutOfBoundsStackPtr If the token passed does not exist in the $phpcsFile. + * @throws \PHPCSUtils\Exceptions\UnexpectedTokenType If the token passed is not a `T_ATTRIBUTE` token. + */ + public static function getAttributes(File $phpcsFile, $stackPtr) + { + $tokens = $phpcsFile->getTokens(); + + if (\is_int($stackPtr) === false) { + throw TypeError::create(2, '$stackPtr', 'integer', $stackPtr); + } + + if (isset($tokens[$stackPtr]) === false) { + throw OutOfBoundsStackPtr::create(2, '$stackPtr', $stackPtr); + } + + if ($tokens[$stackPtr]['code'] !== \T_ATTRIBUTE) { + throw UnexpectedTokenType::create(2, '$stackPtr', 'T_ATTRIBUTE', $tokens[$stackPtr]['type']); + } + + if (isset($tokens[$stackPtr]['attribute_closer']) === false) { + return []; + } + + if (Cache::isCached($phpcsFile, __METHOD__, $stackPtr) === true) { + return Cache::get($phpcsFile, __METHOD__, $stackPtr); + } + + $opener = $stackPtr; + $closer = $tokens[$stackPtr]['attribute_closer']; + + $attributes = []; + $currentName = ''; + $nameToken = null; + $start = ($opener + 1); + + for ($i = ($opener + 1); $i <= $closer; $i++) { + // Skip over potentially large docblocks. + if ($tokens[$i]['code'] === \T_DOC_COMMENT_OPEN_TAG + && isset($tokens[$i]['comment_closer']) + ) { + $i = $tokens[$i]['comment_closer']; + continue; + } + + if (isset(Tokens::$emptyTokens[$tokens[$i]['code']])) { + continue; + } + + if (isset(Collections::namespacedNameTokens()[$tokens[$i]['code']])) { + $currentName .= $tokens[$i]['content']; + $nameToken = $i; + continue; + } + + if ($tokens[$i]['code'] === \T_OPEN_PARENTHESIS + && isset($tokens[$i]['parenthesis_closer']) === true + ) { + // Skip over whatever is passed to the Attribute constructor. + $i = $tokens[$i]['parenthesis_closer']; + continue; + } + + if ($tokens[$i]['code'] === \T_COMMA + || $i === $closer + ) { + // We've reached the end of the name. + if ($currentName === '') { + // Parse error. Stop parsing this attribute block. + break; + } + + $attributes[] = [ + 'name' => $currentName, + 'name_token' => $nameToken, + 'start' => $start, + 'end' => ($i - 1), + 'comma_token' => ($tokens[$i]['code'] === \T_COMMA ? $i : false), + ]; + + if ($i === $closer) { + break; + } + + // Check if there are more tokens before the attribute closer. + // Prevents atrtibute blocks with trailing comma's from setting an extra attribute. + $hasNext = $phpcsFile->findNext(Tokens::$emptyTokens, ($i + 1), $closer, true); + if ($hasNext === false) { + break; + } + + // Prepare for the next attribute instantiation. + $currentName = ''; + $nameToken = null; + $start = ($i + 1); + } + } + + Cache::set($phpcsFile, __METHOD__, $stackPtr, $attributes); + return $attributes; + } + + /** + * Count the number of attributes being instantiated in an attribute block. + * + * @since 1.2.0 + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. + * @param int $stackPtr The position of the T_ATTRIBUTE (attribute opener) token. + * + * @return int + * + * @throws \PHPCSUtils\Exceptions\TypeError If the $stackPtr parameter is not an integer. + * @throws \PHPCSUtils\Exceptions\OutOfBoundsStackPtr If the token passed does not exist in the $phpcsFile. + * @throws \PHPCSUtils\Exceptions\UnexpectedTokenType If the token passed is not a `T_ATTRIBUTE` token. + */ + public static function countAttributes(File $phpcsFile, $stackPtr) + { + return \count(self::getAttributes($phpcsFile, $stackPtr)); + } +} diff --git a/Tests/Utils/AttributeBlock/GetAttributesParseError1Test.inc b/Tests/Utils/AttributeBlock/GetAttributesParseError1Test.inc new file mode 100644 index 00000000..bc69128b --- /dev/null +++ b/Tests/Utils/AttributeBlock/GetAttributesParseError1Test.inc @@ -0,0 +1,7 @@ +getTargetToken('/* testLiveCoding */', \T_ATTRIBUTE); + $result = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr); + + $this->assertSame([], $result); + } +} diff --git a/Tests/Utils/AttributeBlock/GetAttributesParseError2Test.inc b/Tests/Utils/AttributeBlock/GetAttributesParseError2Test.inc new file mode 100644 index 00000000..dbb6cc0b --- /dev/null +++ b/Tests/Utils/AttributeBlock/GetAttributesParseError2Test.inc @@ -0,0 +1,7 @@ +getTargetToken('/* testLiveCoding */', \T_ATTRIBUTE); + $result = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr); + + $this->assertSame([], $result); + } +} diff --git a/Tests/Utils/AttributeBlock/GetAttributesParseError3Test.inc b/Tests/Utils/AttributeBlock/GetAttributesParseError3Test.inc new file mode 100644 index 00000000..3eb2affc --- /dev/null +++ b/Tests/Utils/AttributeBlock/GetAttributesParseError3Test.inc @@ -0,0 +1,7 @@ +getTargetToken('/* testLiveCoding */', \T_ATTRIBUTE); + $result = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr); + + $this->assertSame([], $result); + } +} diff --git a/Tests/Utils/AttributeBlock/GetAttributesTest.inc b/Tests/Utils/AttributeBlock/GetAttributesTest.inc new file mode 100644 index 00000000..8e828295 --- /dev/null +++ b/Tests/Utils/AttributeBlock/GetAttributesTest.inc @@ -0,0 +1,65 @@ +expectException('PHPCSUtils\Exceptions\TypeError'); + $this->expectExceptionMessage('Argument #2 ($stackPtr) must be of type integer, boolean given'); + + AttributeBlock::getAttributes(self::$phpcsFile, false); + } + + /** + * Test receiving an exception when passing a non-existent token pointer. + * + * @return void + */ + public function testNonExistentToken() + { + $this->expectException('PHPCSUtils\Exceptions\OutOfBoundsStackPtr'); + $this->expectExceptionMessage( + 'Argument #2 ($stackPtr) must be a stack pointer which exists in the $phpcsFile object, 100000 given' + ); + + AttributeBlock::getAttributes(self::$phpcsFile, 100000); + } + + /** + * Test receiving an expected exception when a non attribute opener token is passed. + * + * @return void + */ + public function testNotAcceptedTypeException() + { + $this->expectException('PHPCSUtils\Exceptions\UnexpectedTokenType'); + $this->expectExceptionMessage('Argument #2 ($stackPtr) must be of type T_ATTRIBUTE;'); + + $targetPtr = $this->getTargetToken('/* testNotAnAttributeOpener */', \T_ECHO); + AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr); + } + + /** + * Test the getAttributes() method. + * + * @dataProvider dataGetAttributes + * + * @param string $identifier Comment which precedes the test case. + * @param array> $expected Expected function output. + * + * @return void + */ + public function testGetAttributes($identifier, $expected) + { + $targetPtr = $this->getTargetToken($identifier, \T_ATTRIBUTE); + $expected = $this->updateExpectedTokenPositions($targetPtr, $expected); + $result = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr); + + $this->assertSame($expected, $result); + } + + /** + * Data provider. + * + * @see testGetAttributes() + * + * @return array>>> + */ + public static function dataGetAttributes() + { + $data = self::dataAttributes(); + foreach ($data as $key => $value) { + unset($data[$key]['expectedCount']); + } + + return $data; + } + + /** + * Test the countAttributes() method. + * + * @dataProvider dataCountAttributes + * + * @param string $identifier Comment which precedes the test case. + * @param int $expectedCount Expected function output. + * + * @return void + */ + public function testCountAttributes($identifier, $expectedCount) + { + $targetPtr = $this->getTargetToken($identifier, \T_ATTRIBUTE); + $result = AttributeBlock::countAttributes(self::$phpcsFile, $targetPtr); + + $this->assertSame($expectedCount, $result); + } + + /** + * Data provider. + * + * @see testCountAttributes() + * + * @return array> + */ + public static function dataCountAttributes() + { + $data = self::dataAttributes(); + foreach ($data as $key => $value) { + unset($data[$key]['expected']); + } + + return $data; + } + + /** + * Data provider. + * + * Note: token positions are offsets in relation to the position of the T_ATTRIBUTE token! + * + * @see testGetAttributes() + * + * @return array>|int>> + */ + public static function dataAttributes() + { + $php8Names = parent::usesPhp8NameTokens(); + + return [ + 'empty attribute block' => [ + 'identifier' => '/* testEmptyAttributeBlock */', + 'expected' => [], + 'expectedCount' => 0, + ], + + 'single attribute, no parentheses' => [ + 'identifier' => '/* testSingleAttributeNoParens */', + 'expected' => [ + 0 => [ + 'name' => 'SingleAttributeNoParens', + 'name_token' => 1, + 'start' => 1, + 'end' => 1, + 'comma_token' => false, + ], + ], + 'expectedCount' => 1, + ], + 'single attribute, namespace relative name, no parentheses' => [ + 'identifier' => '/* testSingleAttributeNamespaceRelativeNoParens */', + 'expected' => [ + 0 => [ + 'name' => 'namespace\Relative', + 'name_token' => ($php8Names === true ? 1 : 3), + 'start' => 1, + 'end' => ($php8Names === true ? 1 : 3), + 'comma_token' => false, + ], + ], + 'expectedCount' => 1, + ], + 'single attribute, partially qualified name, no parentheses' => [ + 'identifier' => '/* testSingleAttributePartiallyQualifiedNoParensTrailingComma */', + 'expected' => [ + 0 => [ + 'name' => 'Partially\Qualified\Name', + 'name_token' => ($php8Names === true ? 1 : 5), + 'start' => 1, + 'end' => ($php8Names === true ? 1 : 5), + 'comma_token' => ($php8Names === true ? 2 : 6), + ], + ], + 'expectedCount' => 1, + ], + 'single attribute, fully qualified name, no parentheses' => [ + 'identifier' => '/* testSingleAttributeFullyQualifiedNoParensSpacey */', + 'expected' => [ + 0 => [ + 'name' => '\Fully\Qualified\Name', + 'name_token' => ($php8Names === true ? 2 : 7), + 'start' => 1, + 'end' => ($php8Names === true ? 3 : 8), + 'comma_token' => false, + ], + ], + 'expectedCount' => 1, + ], + 'single attribute, parentheses, no parameters' => [ + 'identifier' => '/* testSingleAttributeParensNoParams */', + 'expected' => [ + 0 => [ + 'name' => 'SingleAttributeParensNoParams', + 'name_token' => 1, + 'start' => 1, + 'end' => 3, + 'comma_token' => false, + ], + ], + 'expectedCount' => 1, + ], + 'single attribute, parentheses, no parameters, spacey with comments' => [ + 'identifier' => '/* testSingleAttributeParensNoParamsSpaceyWithComment */', + 'expected' => [ + 0 => [ + 'name' => 'SingleAttributeParensNoParamsSpacey', + 'name_token' => 2, + 'start' => 1, + 'end' => 11, + 'comma_token' => false, + ], + ], + 'expectedCount' => 1, + ], + 'single attribute, with parameters' => [ + 'identifier' => '/* testSingleAttributeParensWithParamsTrailingComma */', + 'expected' => [ + 0 => [ + 'name' => 'SingleAttributeParensWithParams', + 'name_token' => 1, + 'start' => 1, + 'end' => 19, + 'comma_token' => 20, + ], + ], + 'expectedCount' => 1, + ], + 'single attribute, with parameters, multi-line' => [ + 'identifier' => '/* testSingleAttributeParensWithParamsMultiLine */', + 'expected' => [ + 0 => [ + 'name' => 'SingleAttributeParensWithParamsMultiLine', + 'name_token' => 3, + 'start' => 1, + 'end' => 38, + 'comma_token' => false, + ], + ], + 'expectedCount' => 1, + ], + 'single attribute, with named parameters, multi-line' => [ + 'identifier' => '/* testSingleAttributeWithNamedParams */', + 'expected' => [ + 0 => [ + 'name' => 'SingleAttributeWithNamedParams', + 'name_token' => 1, + 'start' => 1, + 'end' => 18, + 'comma_token' => false, + ], + ], + 'expectedCount' => 1, + ], + '4 attributes, single-line' => [ + 'identifier' => '/* testMultipleAttributesSingleLine */', + 'expected' => [ + 0 => [ + 'name' => 'FirstAttribute', + 'name_token' => 1, + 'start' => 1, + 'end' => 3, + 'comma_token' => 4, + ], + 1 => [ + 'name' => 'namespace\SecondAttribute', + 'name_token' => ($php8Names === true ? 6 : 8), + 'start' => 5, + 'end' => ($php8Names === true ? 6 : 8), + 'comma_token' => ($php8Names === true ? 7 : 9), + ], + 2 => [ + 'name' => '\ThirdAttribute', + 'name_token' => ($php8Names === true ? 9 : 12), + 'start' => ($php8Names === true ? 8 : 10), + 'end' => ($php8Names === true ? 15 : 18), + 'comma_token' => ($php8Names === true ? 16 : 19), + ], + 3 => [ + 'name' => 'Partially\FourthAttribute', + 'name_token' => ($php8Names === true ? 18 : 23), + 'start' => ($php8Names === true ? 17 : 20), + 'end' => ($php8Names === true ? 21 : 26), + 'comma_token' => false, + ], + ], + 'expectedCount' => 4, + ], + '2 attributes, single-line, spacey' => [ + 'identifier' => '/* testMultipleAttributesSingleLineTrailingCommaSpacey */', + 'expected' => [ + 0 => [ + 'name' => 'FirstAttribute', + 'name_token' => 2, + 'start' => 1, + 'end' => 5, + 'comma_token' => 6, + ], + 1 => [ + 'name' => '\Fully\Qualified\SecondAttribute', + 'name_token' => ($php8Names === true ? 8 : 13), + 'start' => 7, + 'end' => ($php8Names === true ? 9 : 14), + 'comma_token' => ($php8Names === true ? 10 : 15), + ], + ], + 'expectedCount' => 2, + ], + '4 attributes, multi-line, interlaced with comments' => [ + 'identifier' => '/* testMultipleAttributesMultiLineWithComments */', + 'expected' => [ + 0 => [ + 'name' => '\FirstAttribute', + 'name_token' => ($php8Names === true ? 3 : 4), + 'start' => 1, + 'end' => ($php8Names === true ? 5 : 6), + 'comma_token' => ($php8Names === true ? 6 : 7), + ], + 1 => [ + 'name' => 'Partially\SecondAttribute', + 'name_token' => ($php8Names === true ? 11 : 14), + 'start' => ($php8Names === true ? 7 : 8), + 'end' => ($php8Names === true ? 11 : 14), + 'comma_token' => ($php8Names === true ? 12 : 15), + ], + 2 => [ + 'name' => 'namespace\ThirdAttribute', + 'name_token' => ($php8Names === true ? 26 : 31), + 'start' => ($php8Names === true ? 13 : 16), + 'end' => ($php8Names === true ? 38 : 43), + 'comma_token' => ($php8Names === true ? 39 : 44), + ], + 3 => [ + 'name' => 'FourthAttribute', + 'name_token' => ($php8Names === true ? 45 : 50), + 'start' => ($php8Names === true ? 40 : 45), + 'end' => ($php8Names === true ? 49 : 54), + 'comma_token' => false, + ], + ], + 'expectedCount' => 4, + ], + ]; + } + + /** + * Verify that the build-in caching is used when caching is enabled. + * + * @return void + */ + public function testResultIsCached() + { + $methodName = 'PHPCSUtils\\Utils\\AttributeBlock::getAttributes'; + $cases = self::dataGetAttributes(); + $identifier = $cases['2 attributes, single-line, spacey']['identifier']; + $expected = $cases['2 attributes, single-line, spacey']['expected']; + + $targetPtr = $this->getTargetToken($identifier, \T_ATTRIBUTE); + $expected = $this->updateExpectedTokenPositions($targetPtr, $expected); + + // Verify the caching works. + $origStatus = Cache::$enabled; + Cache::$enabled = true; + + $resultFirstRun = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr); + $isCached = Cache::isCached(self::$phpcsFile, $methodName, $targetPtr); + $resultSecondRun = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr); + + if ($origStatus === false) { + Cache::clear(); + } + Cache::$enabled = $origStatus; + + $this->assertSame($expected, $resultFirstRun, 'First result did not match expectation'); + $this->assertTrue($isCached, 'Cache::isCached() could not find the cached value'); + $this->assertSame($resultFirstRun, $resultSecondRun, 'Second result did not match first'); + } + + /** + * Test helper to translate token offsets to absolute positions in an "expected" array. + * + * @param int $targetPtr The token pointer to the target token from which + * the offset is calculated. + * @param array> $expected The expected function output containing offsets. + * + * @return array> + */ + private function updateExpectedTokenPositions($targetPtr, $expected) + { + foreach ($expected as $key => $attribute) { + $expected[$key]['name_token'] += $targetPtr; + $expected[$key]['start'] += $targetPtr; + $expected[$key]['end'] += $targetPtr; + + if (\is_int($attribute['comma_token']) === true) { + $expected[$key]['comma_token'] += $targetPtr; + } + } + + return $expected; + } +}