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