From cea9566821bcef28901d466f1fdc2b0527435766 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 22 Nov 2025 11:42:20 +0100 Subject: [PATCH] Add support for multi-line array shape annotations in @param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds helper methods to CommentingTrait for collecting type annotations that span multiple lines (e.g., complex array shapes with nested structures). Updates DocBlockParamSniff to use this functionality, allowing it to correctly parse multi-line PHPDoc types like: @param array }> $strings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Sniffs/Commenting/DocBlockParamSniff.php | 21 ++++ PhpCollective/Traits/CommentingTrait.php | 98 +++++++++++++++++++ tests/_data/DocBlockParam/after.php | 51 ++++++++++ tests/_data/DocBlockParam/before.php | 51 ++++++++++ 4 files changed, 221 insertions(+) diff --git a/PhpCollective/Sniffs/Commenting/DocBlockParamSniff.php b/PhpCollective/Sniffs/Commenting/DocBlockParamSniff.php index 71c0de9..c1f1e5e 100644 --- a/PhpCollective/Sniffs/Commenting/DocBlockParamSniff.php +++ b/PhpCollective/Sniffs/Commenting/DocBlockParamSniff.php @@ -88,6 +88,27 @@ public function process(File $phpcsFile, $stackPointer): void continue; } + // Check if this might be a multi-line type (has unclosed brackets) + $openBrackets = substr_count($content, '<') + substr_count($content, '{') + substr_count($content, '('); + $closeBrackets = substr_count($content, '>') + substr_count($content, '}') + substr_count($content, ')'); + + if ($openBrackets > $closeBrackets) { + // Multi-line type annotation - collect across lines + $multiLineResult = $this->collectMultiLineType($phpcsFile, $i, $docBlockEndIndex); + if ($multiLineResult !== null) { + $docBlockParams[] = [ + 'index' => $classNameIndex, + 'type' => $multiLineResult['type'], + 'variable' => $multiLineResult['variable'], + 'appendix' => ' ' . $multiLineResult['variable'] . ($multiLineResult['description'] ? ' ' . $multiLineResult['description'] : ''), + ]; + // Skip to the end of the multi-line annotation + $i = $multiLineResult['endIndex']; + + continue; + } + } + $appendix = ''; $spacePos = strpos($content, ' '); if ($spacePos) { diff --git a/PhpCollective/Traits/CommentingTrait.php b/PhpCollective/Traits/CommentingTrait.php index 5b6dad2..57ff3bb 100644 --- a/PhpCollective/Traits/CommentingTrait.php +++ b/PhpCollective/Traits/CommentingTrait.php @@ -209,6 +209,104 @@ protected function containsIterableSyntax(array $docBlockTypes): bool return false; } + /** + * Collects a potentially multi-line type annotation from a doc block. + * + * This handles complex types like: + * - array + * - Multi-line array shapes with nested structures + * + * @param \PHP_CodeSniffer\Files\File $phpcsFile + * @param int $tagIndex The index of the @param/@return/@var tag + * @param int $docBlockEndIndex The end of the doc block + * + * @return array{type: string, variable: string, description: string, endIndex: int}|null + */ + protected function collectMultiLineType(File $phpcsFile, int $tagIndex, int $docBlockEndIndex): ?array + { + $tokens = $phpcsFile->getTokens(); + + // Find the first content token after the tag + $contentIndex = $tagIndex + 2; + if (!isset($tokens[$contentIndex]) || $tokens[$contentIndex]['type'] !== 'T_DOC_COMMENT_STRING') { + return null; + } + + $collectedContent = ''; + $bracketDepth = 0; + $endIndex = $contentIndex; + + // Collect content across multiple lines if brackets are open + for ($i = $contentIndex; $i < $docBlockEndIndex; $i++) { + $token = $tokens[$i]; + + if ($token['type'] === 'T_DOC_COMMENT_STRING') { + $content = $token['content']; + $collectedContent .= $content; + $endIndex = $i; + + // Count bracket depth + $bracketDepth += substr_count($content, '<') + substr_count($content, '{') + substr_count($content, '('); + $bracketDepth -= substr_count($content, '>') + substr_count($content, '}') + substr_count($content, ')'); + + // If brackets are balanced and we have content, check if we have the full type + if ($bracketDepth <= 0) { + break; + } + } elseif ($token['type'] === 'T_DOC_COMMENT_WHITESPACE') { + // Add a space for line continuations (replacing newlines and asterisks) + if ($bracketDepth > 0 && str_contains($token['content'], "\n")) { + $collectedContent .= ' '; + } + } elseif ($token['type'] === 'T_DOC_COMMENT_STAR') { + // Skip the leading asterisk on continuation lines + continue; + } elseif ($token['type'] === 'T_DOC_COMMENT_TAG') { + // Hit another tag, stop collecting + break; + } + } + + // Normalize whitespace (collapse multiple spaces) + $collectedContent = (string)preg_replace('/\s+/', ' ', trim($collectedContent)); + + // Parse the collected content to extract type, variable, and description + return $this->parseCollectedTypeContent($collectedContent, $endIndex); + } + + /** + * Parse the collected content to extract type, variable name, and description. + * + * @param string $content The collected content + * @param int $endIndex The ending token index + * + * @return array{type: string, variable: string, description: string, endIndex: int}|null + */ + protected function parseCollectedTypeContent(string $content, int $endIndex): ?array + { + // Find the variable name (starts with $) + if (!preg_match('/^(.+?)\s+(\$\S+)(?:\s+(.*))?$/', $content, $matches)) { + // Maybe just a type without variable (for @return) + if (preg_match('/^(\S+)(?:\s+(.*))?$/', $content, $matches)) { + return [ + 'type' => $matches[1], + 'variable' => '', + 'description' => $matches[2] ?? '', + 'endIndex' => $endIndex, + ]; + } + + return null; + } + + return [ + 'type' => trim($matches[1]), + 'variable' => $matches[2], + 'description' => $matches[3] ?? '', + 'endIndex' => $endIndex, + ]; + } + /** * @param array<\PHPStan\PhpDocParser\Ast\Type\TypeNode|string> $typeNodes type nodes * diff --git a/tests/_data/DocBlockParam/after.php b/tests/_data/DocBlockParam/after.php index 65ec044..8cd6f19 100644 --- a/tests/_data/DocBlockParam/after.php +++ b/tests/_data/DocBlockParam/after.php @@ -110,4 +110,55 @@ public function withInheritDoc($param1, $param2): void { // Should not error due to @inheritDoc } + + /** + * Multi-line array shape annotation - should be parsed correctly + * + * @param array, + * comments: array + * }> $strings Extracted strings + * + * @return void + */ + public function multiLineArrayShape(array $strings): void + { + // Should not error - multi-line array shape is valid + } + + /** + * Multi-line with multiple params - should be parsed correctly + * + * @param string $name The name + * @param array{ + * id: int, + * name: string, + * meta?: array + * } $data The data object + * @param bool $flag Optional flag + * + * @return void + */ + public function multiLineWithMultipleParams(string $name, array $data, bool $flag = false): void + { + // Should not error - multi-line array shape with other params + } + + /** + * Nested multi-line generics + * + * @param array> $nested Deeply nested structure + * + * @return void + */ + public function nestedMultiLineGenerics(array $nested): void + { + // Should not error - deeply nested multi-line type + } } \ No newline at end of file diff --git a/tests/_data/DocBlockParam/before.php b/tests/_data/DocBlockParam/before.php index 6c06cb3..f6f94e7 100644 --- a/tests/_data/DocBlockParam/before.php +++ b/tests/_data/DocBlockParam/before.php @@ -111,4 +111,55 @@ public function withInheritDoc($param1, $param2): void { // Should not error due to @inheritDoc } + + /** + * Multi-line array shape annotation - should be parsed correctly + * + * @param array, + * comments: array + * }> $strings Extracted strings + * + * @return void + */ + public function multiLineArrayShape(array $strings): void + { + // Should not error - multi-line array shape is valid + } + + /** + * Multi-line with multiple params - should be parsed correctly + * + * @param string $name The name + * @param array{ + * id: int, + * name: string, + * meta?: array + * } $data The data object + * @param bool $flag Optional flag + * + * @return void + */ + public function multiLineWithMultipleParams(string $name, array $data, bool $flag = false): void + { + // Should not error - multi-line array shape with other params + } + + /** + * Nested multi-line generics + * + * @param array> $nested Deeply nested structure + * + * @return void + */ + public function nestedMultiLineGenerics(array $nested): void + { + // Should not error - deeply nested multi-line type + } } \ No newline at end of file