Skip to content

Commit 1b4153a

Browse files
committed
✨ 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
1 parent 71648cb commit 1b4153a

9 files changed

+812
-0
lines changed
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
/**
3+
* PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
4+
*
5+
* @package PHPCSUtils
6+
* @copyright 2025 PHPCSUtils Contributors
7+
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
8+
* @link https://github.com/PHPCSStandards/PHPCSUtils
9+
*/
10+
11+
namespace PHPCSUtils\Utils;
12+
13+
use PHP_CodeSniffer\Files\File;
14+
use PHP_CodeSniffer\Util\Tokens;
15+
use PHPCSUtils\Exceptions\OutOfBoundsStackPtr;
16+
use PHPCSUtils\Exceptions\TypeError;
17+
use PHPCSUtils\Exceptions\UnexpectedTokenType;
18+
use PHPCSUtils\Internal\Cache;
19+
use PHPCSUtils\Tokens\Collections;
20+
21+
/**
22+
* Utility functions to retrieve information related to attributes.
23+
*
24+
* @since 1.2.0
25+
*/
26+
final class AttributeBlock
27+
{
28+
29+
/**
30+
* Retrieve information on each attribute instantiation within an attribute block.
31+
*
32+
* @since 1.2.0
33+
*
34+
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
35+
* @param int $stackPtr The position of the T_ATTRIBUTE (attribute opener) token.
36+
*
37+
* @return array<array<string, int|string|false>>
38+
* A multi-dimentional array with information on each attribute instantiation in the block.
39+
* The information gathered about each attribute instantiation is in the following format:
40+
* ```php
41+
* array(
42+
* 'name' => string, // The full name of the attribute being instantiated.
43+
* // This will be name as passed without namespace resolution.
44+
* 'name_token' => int, // The stack pointer to the last token in the attribute name.
45+
* // Pro-tip: this token can be passed on to the methods in the
46+
* // {@see PassedParameters} class to retrieve the
47+
* // parameters passed to the attribute constructor.
48+
* 'start' => int, // The stack pointer to the first token in the attribute instantiation.
49+
* // Note: this may be a leading whitespace/comment token.
50+
* 'end' => int, // The stack pointer to the last token in the attribute instantiation.
51+
* // Note: this may be a trailing whitespace/comment token.
52+
* 'comma_token' => int|false, // The stack pointer to the comma after the attribute instantiation
53+
* // or FALSE if this is the last attribute and there is no comma.
54+
* )
55+
* ```
56+
* If no attributes are found, an empty array will be returned.
57+
*
58+
* @throws \PHPCSUtils\Exceptions\TypeError If the $stackPtr parameter is not an integer.
59+
* @throws \PHPCSUtils\Exceptions\OutOfBoundsStackPtr If the token passed does not exist in the $phpcsFile.
60+
* @throws \PHPCSUtils\Exceptions\UnexpectedTokenType If the token passed is not a `T_ATTRIBUTE` token.
61+
*/
62+
public static function getAttributes(File $phpcsFile, $stackPtr)
63+
{
64+
$tokens = $phpcsFile->getTokens();
65+
66+
if (\is_int($stackPtr) === false) {
67+
throw TypeError::create(2, '$stackPtr', 'integer', $stackPtr);
68+
}
69+
70+
if (isset($tokens[$stackPtr]) === false) {
71+
throw OutOfBoundsStackPtr::create(2, '$stackPtr', $stackPtr);
72+
}
73+
74+
if ($tokens[$stackPtr]['code'] !== \T_ATTRIBUTE) {
75+
throw UnexpectedTokenType::create(2, '$stackPtr', 'T_ATTRIBUTE', $tokens[$stackPtr]['type']);
76+
}
77+
78+
if (isset($tokens[$stackPtr]['attribute_closer']) === false) {
79+
return [];
80+
}
81+
82+
if (Cache::isCached($phpcsFile, __METHOD__, $stackPtr) === true) {
83+
return Cache::get($phpcsFile, __METHOD__, $stackPtr);
84+
}
85+
86+
$opener = $stackPtr;
87+
$closer = $tokens[$stackPtr]['attribute_closer'];
88+
89+
$attributes = [];
90+
$currentName = '';
91+
$nameToken = null;
92+
$start = ($opener + 1);
93+
94+
for ($i = ($opener + 1); $i <= $closer; $i++) {
95+
// Skip over potentially large docblocks.
96+
if ($tokens[$i]['code'] === \T_DOC_COMMENT_OPEN_TAG
97+
&& isset($tokens[$i]['comment_closer'])
98+
) {
99+
$i = $tokens[$i]['comment_closer'];
100+
continue;
101+
}
102+
103+
if (isset(Tokens::$emptyTokens[$tokens[$i]['code']])) {
104+
continue;
105+
}
106+
107+
if (isset(Collections::namespacedNameTokens()[$tokens[$i]['code']])) {
108+
$currentName .= $tokens[$i]['content'];
109+
$nameToken = $i;
110+
continue;
111+
}
112+
113+
if ($tokens[$i]['code'] === \T_OPEN_PARENTHESIS
114+
&& isset($tokens[$i]['parenthesis_closer']) === true
115+
) {
116+
// Skip over whatever is passed to the Attribute constructor.
117+
$i = $tokens[$i]['parenthesis_closer'];
118+
continue;
119+
}
120+
121+
if ($tokens[$i]['code'] === \T_COMMA
122+
|| $i === $closer
123+
) {
124+
// We've reached the end of the name.
125+
if ($currentName === '') {
126+
// Parse error. Stop parsing this attribute block.
127+
break;
128+
}
129+
130+
$attributes[] = [
131+
'name' => $currentName,
132+
'name_token' => $nameToken,
133+
'start' => $start,
134+
'end' => ($i - 1),
135+
'comma_token' => ($tokens[$i]['code'] === \T_COMMA ? $i : false),
136+
];
137+
138+
if ($i === $closer) {
139+
break;
140+
}
141+
142+
// Check if there are more tokens before the attribute closer.
143+
// Prevents atrtibute blocks with trailing comma's from setting an extra attribute.
144+
$hasNext = $phpcsFile->findNext(Tokens::$emptyTokens, ($i + 1), $closer, true);
145+
if ($hasNext === false) {
146+
break;
147+
}
148+
149+
// Prepare for the next attribute instantiation.
150+
$currentName = '';
151+
$nameToken = null;
152+
$start = ($i + 1);
153+
}
154+
}
155+
156+
Cache::set($phpcsFile, __METHOD__, $stackPtr, $attributes);
157+
return $attributes;
158+
}
159+
160+
/**
161+
* Count the number of attributes being instantiated in an attribute block.
162+
*
163+
* @since 1.2.0
164+
*
165+
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
166+
* @param int $stackPtr The position of the T_ATTRIBUTE (attribute opener) token.
167+
*
168+
* @return int
169+
*
170+
* @throws \PHPCSUtils\Exceptions\TypeError If the $stackPtr parameter is not an integer.
171+
* @throws \PHPCSUtils\Exceptions\OutOfBoundsStackPtr If the token passed does not exist in the $phpcsFile.
172+
* @throws \PHPCSUtils\Exceptions\UnexpectedTokenType If the token passed is not a `T_ATTRIBUTE` token.
173+
*/
174+
public static function countAttributes(File $phpcsFile, $stackPtr)
175+
{
176+
return \count(self::getAttributes($phpcsFile, $stackPtr));
177+
}
178+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
// Intentional parse error. Live coding. This has to be the last test in the file.
4+
5+
/* testLiveCoding */
6+
#[AttributeName
7+
function hasAttribute() {}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
/**
3+
* PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
4+
*
5+
* @package PHPCSUtils
6+
* @copyright 2025 PHPCSUtils Contributors
7+
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
8+
* @link https://github.com/PHPCSStandards/PHPCSUtils
9+
*/
10+
11+
namespace PHPCSUtils\Tests\Utils\AttributeBlock;
12+
13+
use PHPCSUtils\TestUtils\UtilityMethodTestCase;
14+
use PHPCSUtils\Utils\AttributeBlock;
15+
16+
/**
17+
* Test for the \PHPCSUtils\Utils\AttributeBlock::getAttributes() method.
18+
*
19+
* @covers \PHPCSUtils\Utils\AttributeBlock::getAttributes
20+
*
21+
* @group attributes
22+
*
23+
* @since 1.2.0
24+
*/
25+
final class GetAttributesParseError1Test extends UtilityMethodTestCase
26+
{
27+
28+
/**
29+
* Test that an empty array is returned when an attribute block is unfinished.
30+
*
31+
* @return void
32+
*/
33+
public function testUnfinishedAttribute()
34+
{
35+
$targetPtr = $this->getTargetToken('/* testLiveCoding */', \T_ATTRIBUTE);
36+
$result = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr);
37+
38+
$this->assertSame([], $result);
39+
}
40+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
// Intentional parse error. Live coding. This has to be the last test in the file.
4+
5+
/* testLiveCoding */
6+
#[AttributeName(SOME_CONSTANT, 1
7+
function hasAttribute() {}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
/**
3+
* PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
4+
*
5+
* @package PHPCSUtils
6+
* @copyright 2025 PHPCSUtils Contributors
7+
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
8+
* @link https://github.com/PHPCSStandards/PHPCSUtils
9+
*/
10+
11+
namespace PHPCSUtils\Tests\Utils\AttributeBlock;
12+
13+
use PHPCSUtils\TestUtils\UtilityMethodTestCase;
14+
use PHPCSUtils\Utils\AttributeBlock;
15+
16+
/**
17+
* Test for the \PHPCSUtils\Utils\AttributeBlock::getAttributes() method.
18+
*
19+
* @covers \PHPCSUtils\Utils\AttributeBlock::getAttributes
20+
*
21+
* @group attributes
22+
*
23+
* @since 1.2.0
24+
*/
25+
final class GetAttributesParseError2Test extends UtilityMethodTestCase
26+
{
27+
28+
/**
29+
* Test that an empty array is returned when an attribute block is unfinished.
30+
*
31+
* @return void
32+
*/
33+
public function testUnfinishedAttribute()
34+
{
35+
$targetPtr = $this->getTargetToken('/* testLiveCoding */', \T_ATTRIBUTE);
36+
$result = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr);
37+
38+
$this->assertSame([], $result);
39+
}
40+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
// Intentional parse error. Live coding. This has to be the last test in the file.
4+
5+
/* testLiveCoding */
6+
#[AttributeName(SOME_CONSTANT, AnotherAttribute()]
7+
function hasAttribute() {}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
/**
3+
* PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
4+
*
5+
* @package PHPCSUtils
6+
* @copyright 2025 PHPCSUtils Contributors
7+
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
8+
* @link https://github.com/PHPCSStandards/PHPCSUtils
9+
*/
10+
11+
namespace PHPCSUtils\Tests\Utils\AttributeBlock;
12+
13+
use PHPCSUtils\TestUtils\UtilityMethodTestCase;
14+
use PHPCSUtils\Utils\AttributeBlock;
15+
16+
/**
17+
* Test for the \PHPCSUtils\Utils\AttributeBlock::getAttributes() method.
18+
*
19+
* @covers \PHPCSUtils\Utils\AttributeBlock::getAttributes
20+
*
21+
* @group attributes
22+
*
23+
* @since 1.2.0
24+
*/
25+
final class GetAttributesParseError3Test extends UtilityMethodTestCase
26+
{
27+
28+
/**
29+
* Test that an empty array is returned when an attribute block contains a parse error.
30+
*
31+
* @return void
32+
*/
33+
public function testUnfinishedAttribute()
34+
{
35+
$targetPtr = $this->getTargetToken('/* testLiveCoding */', \T_ATTRIBUTE);
36+
$result = AttributeBlock::getAttributes(self::$phpcsFile, $targetPtr);
37+
38+
$this->assertSame([], $result);
39+
}
40+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/* testNotAnAttributeOpener */
4+
echo 'foo';
5+
6+
/* testEmptyAttributeBlock */
7+
#[]
8+
9+
/* testSingleAttributeNoParens */
10+
#[SingleAttributeNoParens]
11+
12+
/* testSingleAttributeNamespaceRelativeNoParens */
13+
#[namespace\Relative]
14+
15+
/* testSingleAttributePartiallyQualifiedNoParensTrailingComma */
16+
#[Partially\Qualified\Name,]
17+
18+
/* testSingleAttributeFullyQualifiedNoParensSpacey */
19+
#[ \Fully\Qualified\Name ]
20+
21+
/* testSingleAttributeParensNoParams */
22+
#[SingleAttributeParensNoParams()]
23+
24+
/* testSingleAttributeParensNoParamsSpaceyWithComment */
25+
#[ SingleAttributeParensNoParamsSpacey ( /*comment*/ ) /*comment*/ ]
26+
27+
/* testSingleAttributeParensWithParamsTrailingComma */
28+
#[SingleAttributeParensWithParams(1, [1, 2, 3], SOME_CONSTANT,),]
29+
30+
/* testSingleAttributeParensWithParamsMultiLine */
31+
#[
32+
SingleAttributeParensWithParamsMultiLine(
33+
1,
34+
[1, 2, 3],
35+
SOME_CONSTANT * 5,
36+
new Obj,
37+
)
38+
]
39+
40+
/* testSingleAttributeWithNamedParams */
41+
#[SingleAttributeWithNamedParams(
42+
prop: 'val',
43+
other: 5,
44+
)]
45+
46+
/* testMultipleAttributesSingleLine */
47+
#[FirstAttribute(), namespace\SecondAttribute, \ThirdAttribute(1, 2), Partially\FourthAttribute('foo')]
48+
49+
/* testMultipleAttributesSingleLineTrailingCommaSpacey */
50+
#[ FirstAttribute() , \Fully\Qualified\SecondAttribute , ]
51+
52+
/* testMultipleAttributesMultiLineWithComments */
53+
#[
54+
\FirstAttribute(),
55+
// Reason for having this attribute.
56+
Partially\SecondAttribute,
57+
/**
58+
* Docblock, because, why not ?
59+
*/
60+
namespace\ThirdAttribute(1, (2 + 3)),
61+
/* Another comment. */
62+
FourthAttribute('foo')
63+
]
64+
65+
function hasLotsOfAttributes() {}

0 commit comments

Comments
 (0)