Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 127 additions & 8 deletions PhpCollective/Sniffs/Commenting/DocBlockParamSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,20 +269,139 @@ protected function canAddMissingParams(File $phpcsFile, int $docBlockStartIndex,
if ($fix === true) {
$phpcsFile->fixer->beginChangeset();

// Build list of existing param variables
$existingVars = [];
// Build map of existing param variables to their docblock info
$existingParamsByVar = [];
foreach ($docBlockParams as $param) {
$existingVars[] = $param['variable'];
$existingParamsByVar[$param['variable']] = $param;
}

// Add missing params
// Build ordered list of what the @param section should look like
$orderedParams = [];
foreach ($methodSignature as $methodParam) {
$variable = $tokens[$methodParam['variableIndex']]['content'];
if (!in_array($variable, $existingVars, true)) {
$indent = $this->getIndentForParam($phpcsFile, $docBlockStartIndex, $docBlockEndIndex);
$paramLine = "\n" . $indent . '* @param ' . $methodParam['typehintFull'] . ' ' . $variable;
if (isset($existingParamsByVar[$variable])) {
// Use existing param's type (preserve user's more specific type)
$orderedParams[] = [
'type' => $existingParamsByVar[$variable]['type'],
'variable' => $variable,
'appendix' => $existingParamsByVar[$variable]['appendix'],
'existing' => true,
];
} else {
// Add new param with method signature type
$orderedParams[] = [
'type' => $methodParam['typehintFull'],
'variable' => $variable,
'appendix' => ' ' . $variable,
'existing' => false,
];
}
}

// Now we need to add the missing params in the correct positions
// Strategy: find each existing param in the docblock and add missing ones around it

// Build a map of existing @param tag positions by variable
$paramTagsByVar = [];
for ($i = $docBlockStartIndex + 1; $i < $docBlockEndIndex; $i++) {
if ($tokens[$i]['type'] === 'T_DOC_COMMENT_TAG' && $tokens[$i]['content'] === '@param') {
$classNameIndex = $i + 2;
if (isset($tokens[$classNameIndex]) && $tokens[$classNameIndex]['type'] === 'T_DOC_COMMENT_STRING') {
$content = $tokens[$classNameIndex]['content'];
$spacePos = strpos($content, ' ');
if ($spacePos) {
$appendix = substr($content, $spacePos);
preg_match('/\$[^\s]+/', $appendix, $matches);
if ($matches) {
$paramTagsByVar[$matches[0]] = [
'tagIndex' => $i,
'stringIndex' => $classNameIndex,
];
}
}
}
}
}

$indent = $this->getIndentForParam($phpcsFile, $docBlockStartIndex, $docBlockEndIndex);

// Process ordered params - insert missing ones at appropriate positions
$pendingInserts = []; // Params to insert before the first existing one
$lastExistingTagIndex = null;

foreach ($orderedParams as $param) {
if ($param['existing']) {
// This param exists - first flush any pending inserts before it
if ($pendingInserts !== [] && isset($paramTagsByVar[$param['variable']])) {
$tagInfo = $paramTagsByVar[$param['variable']];
// Find the whitespace/newline before this tag
$insertBeforeIndex = $tagInfo['tagIndex'];
for ($j = $tagInfo['tagIndex'] - 1; $j > $docBlockStartIndex; $j--) {
if ($tokens[$j]['type'] === 'T_DOC_COMMENT_WHITESPACE' && $tokens[$j]['content'] !== ' ') {
// This is the indentation whitespace, insert before it
$insertBeforeIndex = $j;

break;
}
}

foreach ($pendingInserts as $pendingParam) {
$paramLine = $indent . '* @param ' . $pendingParam['type'] . ' ' . $pendingParam['variable'] . "\n";
$phpcsFile->fixer->addContentBefore($insertBeforeIndex, $paramLine);
}
$pendingInserts = [];
}
$lastExistingTagIndex = $paramTagsByVar[$param['variable']]['tagIndex'] ?? null;
} else {
// This param needs to be added
if ($lastExistingTagIndex === null) {
// No existing param seen yet - queue it
$pendingInserts[] = $param;
} else {
// Insert after the last existing param's line
// Find the start of the next line (the whitespace token after the newline)
$insertBeforeIndex = null;
$foundNewline = false;
for ($j = $lastExistingTagIndex + 1; $j < $docBlockEndIndex; $j++) {
if ($tokens[$j]['content'] === "\n") {
$foundNewline = true;

continue;
}
if ($foundNewline && $tokens[$j]['type'] === 'T_DOC_COMMENT_WHITESPACE') {
$insertBeforeIndex = $j;

break;
}
}

if ($insertBeforeIndex !== null) {
$paramLine = $indent . '* @param ' . $param['type'] . ' ' . $param['variable'] . "\n";
$phpcsFile->fixer->addContentBefore($insertBeforeIndex, $paramLine);
}
// Update lastExistingTagIndex to track where we just inserted
// (This is tricky - the token indices don't change, but logically we've added after)
}
}
}

// If there are still pending inserts (all params come before existing ones, or no existing params)
if ($pendingInserts !== []) {
// Find the whitespace before the closing tag
$insertBeforeIndex = null;
for ($j = $docBlockEndIndex - 1; $j > $docBlockStartIndex; $j--) {
if ($tokens[$j]['type'] === 'T_DOC_COMMENT_WHITESPACE' && $tokens[$j]['content'] !== "\n" && strpos($tokens[$j]['content'], "\n") === false) {
$insertBeforeIndex = $j;

$phpcsFile->fixer->addContentBefore($insertPosition, $paramLine);
break;
}
}

if ($insertBeforeIndex !== null) {
foreach ($pendingInserts as $pendingParam) {
$paramLine = $indent . '* @param ' . $pendingParam['type'] . ' ' . $pendingParam['variable'] . "\n";
$phpcsFile->fixer->addContentBefore($insertBeforeIndex, $paramLine);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,20 @@ public function testDocBlockParamSniffer(): void
// Line 74: ExtraParam - no params but has @param (fixable - can remove param)
// Line 84: MissingType - missing type in @param (not fixable - needs manual type)
// Line 87: SignatureMismatch - params don't match after missing type (not fixable)
$this->assertSnifferFindsErrors(new DocBlockParamSniff(), 8);
// Line 169: SignatureMismatch - middle param documented, need to add before and after (fixable)
$this->assertSnifferFindsErrors(new DocBlockParamSniff(), 9);
}

/**
* @return void
*/
public function testDocBlockParamFixer(): void
{
// 3 fixable errors:
// 4 fixable errors:
// Line 37: Can add missing @param
// Line 64: Can remove extra @param
// Line 74: Can remove @param when no params
$this->assertSnifferCanFixErrors(new DocBlockParamSniff(), 3);
// Line 169: Can add missing @param before and after existing param (order preserved)
$this->assertSnifferCanFixErrors(new DocBlockParamSniff(), 4);
}
}
12 changes: 12 additions & 0 deletions tests/_data/DocBlockParam/after.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,16 @@ public function nestedMultiLineGenerics(array $nested): void
{
// Should not error - deeply nested multi-line type
}

/**
* Middle param documented - should add before and after
*
* @param Node $parent
* @param array<string> $lines
* @param int $indent
*/
public function middleParamDocumented(Node $parent, array $lines, int $indent): void
{
// Should error: missing @param for $parent (before) and $indent (after)
}
}
10 changes: 10 additions & 0 deletions tests/_data/DocBlockParam/before.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,14 @@ public function nestedMultiLineGenerics(array $nested): void
{
// Should not error - deeply nested multi-line type
}

/**
* Middle param documented - should add before and after
*
* @param array<string> $lines
*/
public function middleParamDocumented(Node $parent, array $lines, int $indent): void
{
// Should error: missing @param for $parent (before) and $indent (after)
}
}