diff --git a/package.xml b/package.xml
index 28c55d4579..deb3f7ec47 100644
--- a/package.xml
+++ b/package.xml
@@ -227,6 +227,8 @@ http://pear.php.net/dtd/package-2.0.xsd">
+
+
@@ -2139,6 +2141,8 @@ http://pear.php.net/dtd/package-2.0.xsd">
+
+
@@ -2223,6 +2227,8 @@ http://pear.php.net/dtd/package-2.0.xsd">
+
+
diff --git a/src/Files/File.php b/src/Files/File.php
index e930a0d099..39e62b6d10 100644
--- a/src/Files/File.php
+++ b/src/Files/File.php
@@ -1813,6 +1813,7 @@ public function getMemberProperties($stackPtr)
T_SEMICOLON,
T_OPEN_CURLY_BRACKET,
T_CLOSE_CURLY_BRACKET,
+ T_ATTRIBUTE_END,
],
($stackPtr - 1)
);
diff --git a/src/Tokenizers/PHP.php b/src/Tokenizers/PHP.php
index 324f72dad3..07651ecfd0 100644
--- a/src/Tokenizers/PHP.php
+++ b/src/Tokenizers/PHP.php
@@ -903,6 +903,50 @@ protected function tokenize($string)
continue;
}//end if
+ /*
+ PHP 8.0 Attributes
+ */
+
+ if (PHP_VERSION_ID < 80000
+ && $token[0] === T_COMMENT
+ && strpos($token[1], '#[') === 0
+ ) {
+ $subTokens = $this->parsePhpAttribute($tokens, $stackPtr);
+ if ($subTokens !== null) {
+ array_splice($tokens, $stackPtr, 1, $subTokens);
+ $numTokens = count($tokens);
+
+ $tokenIsArray = true;
+ $token = $tokens[$stackPtr];
+ } else {
+ $token[0] = T_ATTRIBUTE;
+ }
+ }
+
+ if ($tokenIsArray === true
+ && $token[0] === T_ATTRIBUTE
+ ) {
+ // Go looking for the close bracket.
+ $bracketCloser = $this->findCloser($tokens, ($stackPtr + 1), ['[', '#['], ']');
+
+ $newToken = [];
+ $newToken['code'] = T_ATTRIBUTE;
+ $newToken['type'] = 'T_ATTRIBUTE';
+ $newToken['content'] = '#[';
+ $finalTokens[$newStackPtr] = $newToken;
+
+ $tokens[$bracketCloser] = [];
+ $tokens[$bracketCloser][0] = T_ATTRIBUTE_END;
+ $tokens[$bracketCloser][1] = ']';
+
+ if (PHP_CODESNIFFER_VERBOSITY > 1) {
+ echo "\t\t* token $bracketCloser changed from T_CLOSE_SQUARE_BRACKET to T_ATTRIBUTE_END".PHP_EOL;
+ }
+
+ $newStackPtr++;
+ continue;
+ }//end if
+
/*
Tokenize the parameter labels for PHP 8.0 named parameters as a special T_PARAM_NAME
token and ensure that the colon after it is always T_COLON.
@@ -1857,6 +1901,7 @@ function return types. We want to keep the parenthesis map clean,
T_CLASS => true,
T_EXTENDS => true,
T_IMPLEMENTS => true,
+ T_ATTRIBUTE => true,
T_NEW => true,
T_CONST => true,
T_NS_SEPARATOR => true,
@@ -2114,6 +2159,8 @@ protected function processAdditional()
echo "\t*** START ADDITIONAL PHP PROCESSING ***".PHP_EOL;
}
+ $this->createAttributesNestingMap();
+
$numTokens = count($this->tokens);
for ($i = ($numTokens - 1); $i >= 0; $i--) {
// Check for any unset scope conditions due to alternate IF/ENDIF syntax.
@@ -3089,4 +3136,128 @@ public static function resolveSimpleToken($token)
}//end resolveSimpleToken()
+ /**
+ * Finds a "closer" token (closing parenthesis or square bracket for example)
+ * Handle parenthesis balancing while searching for closing token
+ *
+ * @param array $tokens The list of tokens to iterate searching the closing token (as returned by token_get_all)
+ * @param int $start The starting position
+ * @param string|string[] $openerTokens The opening character
+ * @param string $closerChar The closing character
+ *
+ * @return int|null The position of the closing token, if found. NULL otherwise.
+ */
+ private function findCloser(array &$tokens, $start, $openerTokens, $closerChar)
+ {
+ $numTokens = count($tokens);
+ $stack = [0];
+ $closer = null;
+ $openerTokens = (array) $openerTokens;
+
+ for ($x = $start; $x < $numTokens; $x++) {
+ if (in_array($tokens[$x], $openerTokens, true) === true
+ || (is_array($tokens[$x]) === true && in_array($tokens[$x][1], $openerTokens, true) === true)
+ ) {
+ $stack[] = $x;
+ } else if ($tokens[$x] === $closerChar) {
+ array_pop($stack);
+ if (empty($stack) === true) {
+ $closer = $x;
+ break;
+ }
+ }
+ }
+
+ return $closer;
+
+ }//end findCloser()
+
+
+ /**
+ * PHP 8 attributes parser for PHP < 8
+ * Handles single-line and multiline attributes.
+ *
+ * @param array $tokens The original array of tokens (as returned by token_get_all)
+ * @param int $stackPtr The current position in token array
+ *
+ * @return array|null The array of parsed attribute tokens
+ */
+ private function parsePhpAttribute(array &$tokens, $stackPtr)
+ {
+
+ $token = $tokens[$stackPtr];
+
+ $commentBody = substr($token[1], 2);
+ $subTokens = @token_get_all(' $subToken) {
+ if (is_array($subToken) === true
+ && $subToken[0] === T_COMMENT
+ && strpos($subToken[1], '#[') === 0
+ ) {
+ $reparsed = $this->parsePhpAttribute($subTokens, $i);
+ if ($reparsed !== null) {
+ array_splice($subTokens, $i, 1, $reparsed);
+ } else {
+ $subToken[0] = T_ATTRIBUTE;
+ }
+ }
+ }
+
+ array_splice($subTokens, 0, 1, [[T_ATTRIBUTE, '#[']]);
+
+ // Go looking for the close bracket.
+ $bracketCloser = $this->findCloser($subTokens, 1, '[', ']');
+ if ($bracketCloser === null) {
+ $bracketCloser = $this->findCloser($tokens, $stackPtr, '[', ']');
+ if ($bracketCloser === null) {
+ return null;
+ }
+
+ $subTokens = array_merge($subTokens, array_slice($tokens, ($stackPtr + 1), ($bracketCloser - $stackPtr)));
+ array_splice($tokens, ($stackPtr + 1), ($bracketCloser - $stackPtr));
+ }
+
+ return $subTokens;
+
+ }//end parsePhpAttribute()
+
+
+ /**
+ * Creates a map for the attributes tokens that surround other tokens.
+ *
+ * @return void
+ */
+ private function createAttributesNestingMap()
+ {
+ $map = [];
+ for ($i = 0; $i < $this->numTokens; $i++) {
+ if (isset($this->tokens[$i]['attribute_opener']) === true
+ && $i === $this->tokens[$i]['attribute_opener']
+ ) {
+ if (empty($map) === false) {
+ $this->tokens[$i]['nested_attributes'] = $map;
+ }
+
+ if (isset($this->tokens[$i]['attribute_closer']) === true) {
+ $map[$this->tokens[$i]['attribute_opener']]
+ = $this->tokens[$i]['attribute_closer'];
+ }
+ } else if (isset($this->tokens[$i]['attribute_closer']) === true
+ && $i === $this->tokens[$i]['attribute_closer']
+ ) {
+ array_pop($map);
+ if (empty($map) === false) {
+ $this->tokens[$i]['nested_attributes'] = $map;
+ }
+ } else {
+ if (empty($map) === false) {
+ $this->tokens[$i]['nested_attributes'] = $map;
+ }
+ }//end if
+ }//end for
+
+ }//end createAttributesNestingMap()
+
+
}//end class
diff --git a/src/Tokenizers/Tokenizer.php b/src/Tokenizers/Tokenizer.php
index ac9aa20254..a229d6979e 100644
--- a/src/Tokenizers/Tokenizer.php
+++ b/src/Tokenizers/Tokenizer.php
@@ -740,6 +740,40 @@ private function createTokenMap()
$this->tokens[$i]['parenthesis_closer'] = $i;
$this->tokens[$opener]['parenthesis_closer'] = $i;
}//end if
+ } else if ($this->tokens[$i]['code'] === T_ATTRIBUTE) {
+ $openers[] = $i;
+ if (PHP_CODESNIFFER_VERBOSITY > 1) {
+ echo str_repeat("\t", count($openers));
+ echo "=> Found attribute opener at $i".PHP_EOL;
+ }
+
+ $this->tokens[$i]['attribute_opener'] = $i;
+ $this->tokens[$i]['attribute_closer'] = null;
+ } else if ($this->tokens[$i]['code'] === T_ATTRIBUTE_END) {
+ $numOpeners = count($openers);
+ if ($numOpeners !== 0) {
+ $opener = array_pop($openers);
+ if (isset($this->tokens[$opener]['attribute_opener']) === true) {
+ $this->tokens[$opener]['attribute_closer'] = $i;
+
+ if (PHP_CODESNIFFER_VERBOSITY > 1) {
+ echo str_repeat("\t", (count($openers) + 1));
+ echo "=> Found attribute closer at $i for $opener".PHP_EOL;
+ }
+
+ for ($x = ($opener + 1); $x <= $i; ++$x) {
+ if (isset($this->tokens[$x]['attribute_closer']) === true) {
+ continue;
+ }
+
+ $this->tokens[$x]['attribute_opener'] = $opener;
+ $this->tokens[$x]['attribute_closer'] = $i;
+ }
+ } else if (PHP_CODESNIFFER_VERBOSITY > 1) {
+ echo str_repeat("\t", (count($openers) + 1));
+ echo "=> Found unowned attribute closer at $i for $opener".PHP_EOL;
+ }
+ }//end if
}//end if
/*
diff --git a/src/Util/Tokens.php b/src/Util/Tokens.php
index 7071259b71..56afd27919 100644
--- a/src/Util/Tokens.php
+++ b/src/Util/Tokens.php
@@ -79,6 +79,7 @@
define('T_PARAM_NAME', 'PHPCS_T_PARAM_NAME');
define('T_MATCH_ARROW', 'PHPCS_T_MATCH_ARROW');
define('T_MATCH_DEFAULT', 'PHPCS_T_MATCH_DEFAULT');
+define('T_ATTRIBUTE_END', 'PHPCS_T_ATTRIBUTE_END');
// Some PHP 5.5 tokens, replicated for lower versions.
if (defined('T_FINALLY') === false) {
@@ -149,6 +150,10 @@
define('T_MATCH', 'PHPCS_T_MATCH');
}
+if (defined('T_ATTRIBUTE') === false) {
+ define('T_ATTRIBUTE', 'PHPCS_T_ATTRIBUTE');
+}
+
// Tokens used for parsing doc blocks.
define('T_DOC_COMMENT_STAR', 'PHPCS_T_DOC_COMMENT_STAR');
define('T_DOC_COMMENT_WHITESPACE', 'PHPCS_T_DOC_COMMENT_WHITESPACE');
diff --git a/tests/Core/File/GetMemberPropertiesTest.inc b/tests/Core/File/GetMemberPropertiesTest.inc
index 9cc56bd53a..ea47e9fc57 100644
--- a/tests/Core/File/GetMemberPropertiesTest.inc
+++ b/tests/Core/File/GetMemberPropertiesTest.inc
@@ -240,3 +240,19 @@ $anon = class() {
// Intentional fatal error - duplicate types are not allowed in union types, but that's not the concern of the method.
public int |string| /*comment*/ INT $duplicateTypeInUnion;
};
+
+$anon = class {
+ /* testPHP8PropertySingleAttribute */
+ #[PropertyWithAttribute]
+ public string $foo;
+
+ /* testPHP8PropertyMultipleAttributes */
+ #[PropertyWithAttribute(foo: 'bar'), MyAttribute]
+ protected ?int|float $bar;
+
+ /* testPHP8PropertyMultilineAttribute */
+ #[
+ PropertyWithAttribute(/* comment */ 'baz')
+ ]
+ private mixed $baz;
+};
diff --git a/tests/Core/File/GetMemberPropertiesTest.php b/tests/Core/File/GetMemberPropertiesTest.php
index a59effc752..0af0437932 100644
--- a/tests/Core/File/GetMemberPropertiesTest.php
+++ b/tests/Core/File/GetMemberPropertiesTest.php
@@ -610,6 +610,36 @@ public function dataGetMemberProperties()
'nullable_type' => false,
],
],
+ [
+ '/* testPHP8PropertySingleAttribute */',
+ [
+ 'scope' => 'public',
+ 'scope_specified' => true,
+ 'is_static' => false,
+ 'type' => 'string',
+ 'nullable_type' => false,
+ ],
+ ],
+ [
+ '/* testPHP8PropertyMultipleAttributes */',
+ [
+ 'scope' => 'protected',
+ 'scope_specified' => true,
+ 'is_static' => false,
+ 'type' => '?int|float',
+ 'nullable_type' => true,
+ ],
+ ],
+ [
+ '/* testPHP8PropertyMultilineAttribute */',
+ [
+ 'scope' => 'private',
+ 'scope_specified' => true,
+ 'is_static' => false,
+ 'type' => 'mixed',
+ 'nullable_type' => false,
+ ],
+ ],
];
}//end dataGetMemberProperties()
diff --git a/tests/Core/Tokenizer/AttributesTest.inc b/tests/Core/Tokenizer/AttributesTest.inc
new file mode 100644
index 0000000000..9b7b869d13
--- /dev/null
+++ b/tests/Core/Tokenizer/AttributesTest.inc
@@ -0,0 +1,81 @@
+ 'foobar'])]
+function attribute_with_params_on_function_test() {}
+
+/* testAttributeWithShortClosureParameter */
+#[AttributeWithParams(static fn ($value) => ! $value)]
+function attribute_with_short_closure_param_test() {}
+
+/* testTwoAttributeOnTheSameLine */
+#[CustomAttribute] #[AttributeWithParams('foo')]
+function two_attribute_on_same_line_test() {}
+
+/* testAttributeAndCommentOnTheSameLine */
+#[CustomAttribute] // This is a comment
+function attribute_and_line_comment_on_same_line_test() {}
+
+/* testAttributeGrouping */
+#[CustomAttribute, AttributeWithParams('foo'), AttributeWithParams('foo', bar: ['bar' => 'foobar'])]
+function attribute_grouping_test() {}
+
+/* testAttributeMultiline */
+#[
+ CustomAttribute,
+ AttributeWithParams('foo'),
+ AttributeWithParams('foo', bar: ['bar' => 'foobar'])
+]
+function attribute_multiline_test() {}
+
+/* testAttributeMultilineWithComment */
+#[
+ CustomAttribute, // comment
+ AttributeWithParams(/* another comment */ 'foo'),
+ AttributeWithParams('foo', bar: ['bar' => 'foobar'])
+]
+function attribute_multiline_with_comment_test() {}
+
+/* testSingleAttributeOnParameter */
+function single_attribute_on_parameter_test(#[ParamAttribute] int $param) {}
+
+/* testMultipleAttributesOnParameter */
+function multiple_attributes_on_parameter_test(#[ParamAttribute, AttributeWithParams(/* another comment */ 'foo')] int $param) {}
+
+/* testFqcnAttribute */
+#[Boo\QualifiedName, \Foo\FullyQualifiedName('foo')]
+function fqcn_attrebute_test() {}
+
+/* testNestedAttributes */
+#[Boo\QualifiedName(fn (#[AttributeOne('boo')] $value) => (string) $value)]
+function nested_attributes_test() {}
+
+/* testMultilineAttributesOnParameter */
+function multiline_attributes_on_parameter_test(#[
+ AttributeWithParams(
+ 'foo'
+ )
+ ] int $param) {}
+
+/* testInvalidAttribute */
+#[ThisIsNotAnAttribute
+function invalid_attribute_test() {}
+
diff --git a/tests/Core/Tokenizer/AttributesTest.php b/tests/Core/Tokenizer/AttributesTest.php
new file mode 100644
index 0000000000..46d8365431
--- /dev/null
+++ b/tests/Core/Tokenizer/AttributesTest.php
@@ -0,0 +1,508 @@
+
+ * @copyright 2019 Squiz Pty Ltd (ABN 77 084 670 600)
+ * @license https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
+ */
+
+namespace PHP_CodeSniffer\Tests\Core\Tokenizer;
+
+use PHP_CodeSniffer\Tests\Core\AbstractMethodUnitTest;
+use PHP_CodeSniffer\Util\Tokens;
+
+class AttributesTest extends AbstractMethodUnitTest
+{
+
+
+ /**
+ * Test that attributes are parsed correctly.
+ *
+ * @param string $testMarker The comment which prefaces the target token in the test file.
+ * @param int $length The number of tokens between opener and closer.
+ * @param array $tokenCodes The codes of tokens inside the attributes.
+ *
+ * @dataProvider dataAttribute
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::tokenize
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::findCloser
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::parsePhpAttribute
+ *
+ * @return void
+ */
+ public function testAttribute($testMarker, $length, $tokenCodes)
+ {
+ $tokens = self::$phpcsFile->getTokens();
+
+ $attribute = $this->getTargetToken($testMarker, T_ATTRIBUTE);
+ $this->assertArrayHasKey('attribute_closer', $tokens[$attribute]);
+
+ $closer = $tokens[$attribute]['attribute_closer'];
+ $this->assertSame(($attribute + $length), $closer);
+
+ $this->assertSame(T_ATTRIBUTE_END, $tokens[$closer]['code']);
+
+ $this->assertSame($tokens[$attribute]['attribute_opener'], $tokens[$closer]['attribute_opener']);
+ $this->assertSame($tokens[$attribute]['attribute_closer'], $tokens[$closer]['attribute_closer']);
+
+ $map = array_map(
+ function ($token) use ($attribute, $length) {
+ $this->assertArrayHasKey('attribute_closer', $token);
+ $this->assertSame(($attribute + $length), $token['attribute_closer']);
+
+ return $token['code'];
+ },
+ array_slice($tokens, ($attribute + 1), ($length - 1))
+ );
+
+ $this->assertSame($tokenCodes, $map);
+
+ }//end testAttribute()
+
+
+ /**
+ * Data provider.
+ *
+ * @see testAttribute()
+ *
+ * @return array
+ */
+ public function dataAttribute()
+ {
+ return [
+ [
+ '/* testAttribute */',
+ 2,
+ [ T_STRING ],
+ ],
+ [
+ '/* testAttributeWithParams */',
+ 7,
+ [
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_STRING,
+ T_DOUBLE_COLON,
+ T_STRING,
+ T_CLOSE_PARENTHESIS,
+ ],
+ ],
+ [
+ '/* testAttributeWithNamedParam */',
+ 10,
+ [
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_PARAM_NAME,
+ T_COLON,
+ T_WHITESPACE,
+ T_STRING,
+ T_DOUBLE_COLON,
+ T_STRING,
+ T_CLOSE_PARENTHESIS,
+ ],
+ ],
+ [
+ '/* testAttributeOnFunction */',
+ 2,
+ [ T_STRING ],
+ ],
+ [
+ '/* testAttributeOnFunctionWithParams */',
+ 17,
+ [
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_COMMA,
+ T_WHITESPACE,
+ T_PARAM_NAME,
+ T_COLON,
+ T_WHITESPACE,
+ T_OPEN_SHORT_ARRAY,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_WHITESPACE,
+ T_DOUBLE_ARROW,
+ T_WHITESPACE,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_CLOSE_SHORT_ARRAY,
+ T_CLOSE_PARENTHESIS,
+ ],
+ ],
+ [
+ '/* testAttributeWithShortClosureParameter */',
+ 17,
+ [
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_STATIC,
+ T_WHITESPACE,
+ T_FN,
+ T_WHITESPACE,
+ T_OPEN_PARENTHESIS,
+ T_VARIABLE,
+ T_CLOSE_PARENTHESIS,
+ T_WHITESPACE,
+ T_FN_ARROW,
+ T_WHITESPACE,
+ T_BOOLEAN_NOT,
+ T_WHITESPACE,
+ T_VARIABLE,
+ T_CLOSE_PARENTHESIS,
+ ],
+ ],
+ [
+ '/* testAttributeGrouping */',
+ 26,
+ [
+ T_STRING,
+ T_COMMA,
+ T_WHITESPACE,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_CLOSE_PARENTHESIS,
+ T_COMMA,
+ T_WHITESPACE,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_COMMA,
+ T_WHITESPACE,
+ T_PARAM_NAME,
+ T_COLON,
+ T_WHITESPACE,
+ T_OPEN_SHORT_ARRAY,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_WHITESPACE,
+ T_DOUBLE_ARROW,
+ T_WHITESPACE,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_CLOSE_SHORT_ARRAY,
+ T_CLOSE_PARENTHESIS,
+ ],
+ ],
+ [
+ '/* testAttributeMultiline */',
+ 31,
+ [
+ T_WHITESPACE,
+ T_WHITESPACE,
+ T_STRING,
+ T_COMMA,
+ T_WHITESPACE,
+ T_WHITESPACE,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_CLOSE_PARENTHESIS,
+ T_COMMA,
+ T_WHITESPACE,
+ T_WHITESPACE,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_COMMA,
+ T_WHITESPACE,
+ T_PARAM_NAME,
+ T_COLON,
+ T_WHITESPACE,
+ T_OPEN_SHORT_ARRAY,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_WHITESPACE,
+ T_DOUBLE_ARROW,
+ T_WHITESPACE,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_CLOSE_SHORT_ARRAY,
+ T_CLOSE_PARENTHESIS,
+ T_WHITESPACE,
+ ],
+ ],
+ [
+ '/* testFqcnAttribute */',
+ 13,
+ [
+ T_STRING,
+ T_NS_SEPARATOR,
+ T_STRING,
+ T_COMMA,
+ T_WHITESPACE,
+ T_NS_SEPARATOR,
+ T_STRING,
+ T_NS_SEPARATOR,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_CLOSE_PARENTHESIS,
+ ],
+ ],
+ ];
+
+ }//end dataAttribute()
+
+
+ /**
+ * Test that multiple attributes on the same line are parsed correctly.
+ *
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::tokenize
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::findCloser
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::parsePhpAttribute
+ *
+ * @return void
+ */
+ public function testTwoAttributesOnTheSameLine()
+ {
+ $tokens = self::$phpcsFile->getTokens();
+
+ $attribute = $this->getTargetToken('/* testTwoAttributeOnTheSameLine */', T_ATTRIBUTE);
+ $this->assertArrayHasKey('attribute_closer', $tokens[$attribute]);
+
+ $closer = $tokens[$attribute]['attribute_closer'];
+ $this->assertSame(T_WHITESPACE, $tokens[($closer + 1)]['code']);
+ $this->assertSame(T_ATTRIBUTE, $tokens[($closer + 2)]['code']);
+ $this->assertArrayHasKey('attribute_closer', $tokens[($closer + 2)]);
+
+ }//end testTwoAttributesOnTheSameLine()
+
+
+ /**
+ * Test that attribute followed by a line comment is parsed correctly.
+ *
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::tokenize
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::findCloser
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::parsePhpAttribute
+ *
+ * @return void
+ */
+ public function testAttributeAndLineComment()
+ {
+ $tokens = self::$phpcsFile->getTokens();
+
+ $attribute = $this->getTargetToken('/* testAttributeAndCommentOnTheSameLine */', T_ATTRIBUTE);
+ $this->assertArrayHasKey('attribute_closer', $tokens[$attribute]);
+
+ $closer = $tokens[$attribute]['attribute_closer'];
+ $this->assertSame(T_WHITESPACE, $tokens[($closer + 1)]['code']);
+ $this->assertSame(T_COMMENT, $tokens[($closer + 2)]['code']);
+
+ }//end testAttributeAndLineComment()
+
+
+ /**
+ * Test that attribute followed by a line comment is parsed correctly.
+ *
+ * @param string $testMarker The comment which prefaces the target token in the test file.
+ * @param int $position The token position (starting from T_FUNCTION) of T_ATTRIBUTE token.
+ * @param int $length The number of tokens between opener and closer.
+ * @param array $tokenCodes The codes of tokens inside the attributes.
+ *
+ * @dataProvider dataAttributeOnParameters
+ *
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::tokenize
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::findCloser
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::parsePhpAttribute
+ *
+ * @return void
+ */
+ public function testAttributeOnParameters($testMarker, $position, $length, array $tokenCodes)
+ {
+ $tokens = self::$phpcsFile->getTokens();
+
+ $function = $this->getTargetToken($testMarker, T_FUNCTION);
+ $attribute = ($function + $position);
+
+ $this->assertSame(T_ATTRIBUTE, $tokens[$attribute]['code']);
+ $this->assertArrayHasKey('attribute_closer', $tokens[$attribute]);
+
+ $this->assertSame(($attribute + $length), $tokens[$attribute]['attribute_closer']);
+
+ $closer = $tokens[$attribute]['attribute_closer'];
+ $this->assertSame(T_WHITESPACE, $tokens[($closer + 1)]['code']);
+ $this->assertSame(T_STRING, $tokens[($closer + 2)]['code']);
+ $this->assertSame('int', $tokens[($closer + 2)]['content']);
+
+ $this->assertSame(T_VARIABLE, $tokens[($closer + 4)]['code']);
+ $this->assertSame('$param', $tokens[($closer + 4)]['content']);
+
+ $map = array_map(
+ function ($token) use ($attribute, $length) {
+ $this->assertArrayHasKey('attribute_closer', $token);
+ $this->assertSame(($attribute + $length), $token['attribute_closer']);
+
+ return $token['code'];
+ },
+ array_slice($tokens, ($attribute + 1), ($length - 1))
+ );
+
+ $this->assertSame($tokenCodes, $map);
+
+ }//end testAttributeOnParameters()
+
+
+ /**
+ * Data provider.
+ *
+ * @see testAttributeOnParameters()
+ *
+ * @return array
+ */
+ public function dataAttributeOnParameters()
+ {
+ return [
+ [
+ '/* testSingleAttributeOnParameter */',
+ 4,
+ 2,
+ [T_STRING],
+ ],
+ [
+ '/* testMultipleAttributesOnParameter */',
+ 4,
+ 10,
+ [
+ T_STRING,
+ T_COMMA,
+ T_WHITESPACE,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_COMMENT,
+ T_WHITESPACE,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_CLOSE_PARENTHESIS,
+ ],
+ ],
+ [
+ '/* testMultilineAttributesOnParameter */',
+ 4,
+ 13,
+ [
+ T_WHITESPACE,
+ T_WHITESPACE,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_WHITESPACE,
+ T_WHITESPACE,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_WHITESPACE,
+ T_WHITESPACE,
+ T_CLOSE_PARENTHESIS,
+ T_WHITESPACE,
+ T_WHITESPACE,
+ ],
+ ],
+ ];
+
+ }//end dataAttributeOnParameters()
+
+
+ /**
+ * Test that invalid attribute (or comment starting with #[ and without ]) are parsed correctly.
+ *
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::tokenize
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::findCloser
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::parsePhpAttribute
+ *
+ * @return void
+ */
+ public function testInvalidAttribute()
+ {
+ $tokens = self::$phpcsFile->getTokens();
+
+ $attribute = $this->getTargetToken('/* testInvalidAttribute */', T_ATTRIBUTE);
+
+ $this->assertArrayHasKey('attribute_closer', $tokens[$attribute]);
+ $this->assertNull($tokens[$attribute]['attribute_closer']);
+
+ }//end testInvalidAttribute()
+
+
+ /**
+ * Test that nested attributes are parsed correctly.
+ *
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::tokenize
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::findCloser
+ * @covers PHP_CodeSniffer\Tokenizers\PHP::parsePhpAttribute
+ *
+ * @return void
+ */
+ public function testNestedAttributes()
+ {
+ $tokens = self::$phpcsFile->getTokens();
+ $tokenCodes = [
+ T_STRING,
+ T_NS_SEPARATOR,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_FN,
+ T_WHITESPACE,
+ T_OPEN_PARENTHESIS,
+ T_ATTRIBUTE,
+ T_STRING,
+ T_OPEN_PARENTHESIS,
+ T_CONSTANT_ENCAPSED_STRING,
+ T_CLOSE_PARENTHESIS,
+ T_ATTRIBUTE_END,
+ T_WHITESPACE,
+ T_VARIABLE,
+ T_CLOSE_PARENTHESIS,
+ T_WHITESPACE,
+ T_FN_ARROW,
+ T_WHITESPACE,
+ T_STRING_CAST,
+ T_WHITESPACE,
+ T_VARIABLE,
+ T_CLOSE_PARENTHESIS,
+ ];
+
+ $attribute = $this->getTargetToken('/* testNestedAttributes */', T_ATTRIBUTE);
+ $this->assertArrayHasKey('attribute_closer', $tokens[$attribute]);
+
+ $closer = $tokens[$attribute]['attribute_closer'];
+ $this->assertSame(($attribute + 24), $closer);
+
+ $this->assertSame(T_ATTRIBUTE_END, $tokens[$closer]['code']);
+
+ $this->assertSame($tokens[$attribute]['attribute_opener'], $tokens[$closer]['attribute_opener']);
+ $this->assertSame($tokens[$attribute]['attribute_closer'], $tokens[$closer]['attribute_closer']);
+
+ $this->assertArrayNotHasKey('nested_attributes', $tokens[$attribute]);
+ $this->assertArrayHasKey('nested_attributes', $tokens[($attribute + 8)]);
+ $this->assertSame([$attribute => ($attribute + 24)], $tokens[($attribute + 8)]['nested_attributes']);
+
+ $test = function (array $tokens, $length, $nestedMap) use ($attribute) {
+ foreach ($tokens as $token) {
+ $this->assertArrayHasKey('attribute_closer', $token);
+ $this->assertSame(($attribute + $length), $token['attribute_closer']);
+ $this->assertSame($nestedMap, $token['nested_attributes']);
+ }
+ };
+
+ $test(array_slice($tokens, ($attribute + 1), 7), 24, [$attribute => $attribute + 24]);
+ $test(array_slice($tokens, ($attribute + 8), 1), 8 + 5, [$attribute => $attribute + 24]);
+
+ // Length here is 8 (nested attribute offset) + 5 (real length).
+ $test(
+ array_slice($tokens, ($attribute + 9), 4),
+ 8 + 5,
+ [
+ $attribute => $attribute + 24,
+ $attribute + 8 => $attribute + 13,
+ ]
+ );
+
+ $test(array_slice($tokens, ($attribute + 13), 1), 8 + 5, [$attribute => $attribute + 24]);
+ $test(array_slice($tokens, ($attribute + 14), 10), 24, [$attribute => $attribute + 24]);
+
+ $map = array_map(
+ static function ($token) {
+ return $token['code'];
+ },
+ array_slice($tokens, ($attribute + 1), 23)
+ );
+
+ $this->assertSame($tokenCodes, $map);
+
+ }//end testNestedAttributes()
+
+
+}//end class