Skip to content

Commit 0773620

Browse files
dereuromarkclaude
andauthored
Add support for multi-line array shape annotations in @param (#38)
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<string, array{ msgid: string, msgid_plural: string|null, references: array<string> }> $strings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1d20fe commit 0773620

File tree

4 files changed

+221
-0
lines changed

4 files changed

+221
-0
lines changed

PhpCollective/Sniffs/Commenting/DocBlockParamSniff.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,27 @@ public function process(File $phpcsFile, $stackPointer): void
8888
continue;
8989
}
9090

91+
// Check if this might be a multi-line type (has unclosed brackets)
92+
$openBrackets = substr_count($content, '<') + substr_count($content, '{') + substr_count($content, '(');
93+
$closeBrackets = substr_count($content, '>') + substr_count($content, '}') + substr_count($content, ')');
94+
95+
if ($openBrackets > $closeBrackets) {
96+
// Multi-line type annotation - collect across lines
97+
$multiLineResult = $this->collectMultiLineType($phpcsFile, $i, $docBlockEndIndex);
98+
if ($multiLineResult !== null) {
99+
$docBlockParams[] = [
100+
'index' => $classNameIndex,
101+
'type' => $multiLineResult['type'],
102+
'variable' => $multiLineResult['variable'],
103+
'appendix' => ' ' . $multiLineResult['variable'] . ($multiLineResult['description'] ? ' ' . $multiLineResult['description'] : ''),
104+
];
105+
// Skip to the end of the multi-line annotation
106+
$i = $multiLineResult['endIndex'];
107+
108+
continue;
109+
}
110+
}
111+
91112
$appendix = '';
92113
$spacePos = strpos($content, ' ');
93114
if ($spacePos) {

PhpCollective/Traits/CommentingTrait.php

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,104 @@ protected function containsIterableSyntax(array $docBlockTypes): bool
209209
return false;
210210
}
211211

212+
/**
213+
* Collects a potentially multi-line type annotation from a doc block.
214+
*
215+
* This handles complex types like:
216+
* - array<string, array{msgid: string, msgid_plural: string|null}>
217+
* - Multi-line array shapes with nested structures
218+
*
219+
* @param \PHP_CodeSniffer\Files\File $phpcsFile
220+
* @param int $tagIndex The index of the @param/@return/@var tag
221+
* @param int $docBlockEndIndex The end of the doc block
222+
*
223+
* @return array{type: string, variable: string, description: string, endIndex: int}|null
224+
*/
225+
protected function collectMultiLineType(File $phpcsFile, int $tagIndex, int $docBlockEndIndex): ?array
226+
{
227+
$tokens = $phpcsFile->getTokens();
228+
229+
// Find the first content token after the tag
230+
$contentIndex = $tagIndex + 2;
231+
if (!isset($tokens[$contentIndex]) || $tokens[$contentIndex]['type'] !== 'T_DOC_COMMENT_STRING') {
232+
return null;
233+
}
234+
235+
$collectedContent = '';
236+
$bracketDepth = 0;
237+
$endIndex = $contentIndex;
238+
239+
// Collect content across multiple lines if brackets are open
240+
for ($i = $contentIndex; $i < $docBlockEndIndex; $i++) {
241+
$token = $tokens[$i];
242+
243+
if ($token['type'] === 'T_DOC_COMMENT_STRING') {
244+
$content = $token['content'];
245+
$collectedContent .= $content;
246+
$endIndex = $i;
247+
248+
// Count bracket depth
249+
$bracketDepth += substr_count($content, '<') + substr_count($content, '{') + substr_count($content, '(');
250+
$bracketDepth -= substr_count($content, '>') + substr_count($content, '}') + substr_count($content, ')');
251+
252+
// If brackets are balanced and we have content, check if we have the full type
253+
if ($bracketDepth <= 0) {
254+
break;
255+
}
256+
} elseif ($token['type'] === 'T_DOC_COMMENT_WHITESPACE') {
257+
// Add a space for line continuations (replacing newlines and asterisks)
258+
if ($bracketDepth > 0 && str_contains($token['content'], "\n")) {
259+
$collectedContent .= ' ';
260+
}
261+
} elseif ($token['type'] === 'T_DOC_COMMENT_STAR') {
262+
// Skip the leading asterisk on continuation lines
263+
continue;
264+
} elseif ($token['type'] === 'T_DOC_COMMENT_TAG') {
265+
// Hit another tag, stop collecting
266+
break;
267+
}
268+
}
269+
270+
// Normalize whitespace (collapse multiple spaces)
271+
$collectedContent = (string)preg_replace('/\s+/', ' ', trim($collectedContent));
272+
273+
// Parse the collected content to extract type, variable, and description
274+
return $this->parseCollectedTypeContent($collectedContent, $endIndex);
275+
}
276+
277+
/**
278+
* Parse the collected content to extract type, variable name, and description.
279+
*
280+
* @param string $content The collected content
281+
* @param int $endIndex The ending token index
282+
*
283+
* @return array{type: string, variable: string, description: string, endIndex: int}|null
284+
*/
285+
protected function parseCollectedTypeContent(string $content, int $endIndex): ?array
286+
{
287+
// Find the variable name (starts with $)
288+
if (!preg_match('/^(.+?)\s+(\$\S+)(?:\s+(.*))?$/', $content, $matches)) {
289+
// Maybe just a type without variable (for @return)
290+
if (preg_match('/^(\S+)(?:\s+(.*))?$/', $content, $matches)) {
291+
return [
292+
'type' => $matches[1],
293+
'variable' => '',
294+
'description' => $matches[2] ?? '',
295+
'endIndex' => $endIndex,
296+
];
297+
}
298+
299+
return null;
300+
}
301+
302+
return [
303+
'type' => trim($matches[1]),
304+
'variable' => $matches[2],
305+
'description' => $matches[3] ?? '',
306+
'endIndex' => $endIndex,
307+
];
308+
}
309+
212310
/**
213311
* @param array<\PHPStan\PhpDocParser\Ast\Type\TypeNode|string> $typeNodes type nodes
214312
*

tests/_data/DocBlockParam/after.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,55 @@ public function withInheritDoc($param1, $param2): void
110110
{
111111
// Should not error due to @inheritDoc
112112
}
113+
114+
/**
115+
* Multi-line array shape annotation - should be parsed correctly
116+
*
117+
* @param array<string, array{
118+
* msgid: string,
119+
* msgid_plural: string|null,
120+
* msgctxt: string|null,
121+
* references: array<string>,
122+
* comments: array<string>
123+
* }> $strings Extracted strings
124+
*
125+
* @return void
126+
*/
127+
public function multiLineArrayShape(array $strings): void
128+
{
129+
// Should not error - multi-line array shape is valid
130+
}
131+
132+
/**
133+
* Multi-line with multiple params - should be parsed correctly
134+
*
135+
* @param string $name The name
136+
* @param array{
137+
* id: int,
138+
* name: string,
139+
* meta?: array<string, mixed>
140+
* } $data The data object
141+
* @param bool $flag Optional flag
142+
*
143+
* @return void
144+
*/
145+
public function multiLineWithMultipleParams(string $name, array $data, bool $flag = false): void
146+
{
147+
// Should not error - multi-line array shape with other params
148+
}
149+
150+
/**
151+
* Nested multi-line generics
152+
*
153+
* @param array<int, array<string, array{
154+
* key: string,
155+
* value: mixed
156+
* }>> $nested Deeply nested structure
157+
*
158+
* @return void
159+
*/
160+
public function nestedMultiLineGenerics(array $nested): void
161+
{
162+
// Should not error - deeply nested multi-line type
163+
}
113164
}

tests/_data/DocBlockParam/before.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,55 @@ public function withInheritDoc($param1, $param2): void
111111
{
112112
// Should not error due to @inheritDoc
113113
}
114+
115+
/**
116+
* Multi-line array shape annotation - should be parsed correctly
117+
*
118+
* @param array<string, array{
119+
* msgid: string,
120+
* msgid_plural: string|null,
121+
* msgctxt: string|null,
122+
* references: array<string>,
123+
* comments: array<string>
124+
* }> $strings Extracted strings
125+
*
126+
* @return void
127+
*/
128+
public function multiLineArrayShape(array $strings): void
129+
{
130+
// Should not error - multi-line array shape is valid
131+
}
132+
133+
/**
134+
* Multi-line with multiple params - should be parsed correctly
135+
*
136+
* @param string $name The name
137+
* @param array{
138+
* id: int,
139+
* name: string,
140+
* meta?: array<string, mixed>
141+
* } $data The data object
142+
* @param bool $flag Optional flag
143+
*
144+
* @return void
145+
*/
146+
public function multiLineWithMultipleParams(string $name, array $data, bool $flag = false): void
147+
{
148+
// Should not error - multi-line array shape with other params
149+
}
150+
151+
/**
152+
* Nested multi-line generics
153+
*
154+
* @param array<int, array<string, array{
155+
* key: string,
156+
* value: mixed
157+
* }>> $nested Deeply nested structure
158+
*
159+
* @return void
160+
*/
161+
public function nestedMultiLineGenerics(array $nested): void
162+
{
163+
// Should not error - deeply nested multi-line type
164+
}
114165
}

0 commit comments

Comments
 (0)