diff --git a/SlevomatCodingStandard/Helpers/UseStatementHelper.php b/SlevomatCodingStandard/Helpers/UseStatementHelper.php index aadfeef02..79a7a4fa0 100644 --- a/SlevomatCodingStandard/Helpers/UseStatementHelper.php +++ b/SlevomatCodingStandard/Helpers/UseStatementHelper.php @@ -163,17 +163,47 @@ public static function getFileUseStatements(File $phpcsFile): array $namespaceAndOpenTagPointers = TokenHelper::findNextAll($phpcsFile, [T_OPEN_TAG, T_NAMESPACE], 0); $openTagPointer = $namespaceAndOpenTagPointers[0]; + $currentBlockKey = null; + $previousSemicolon = null; + foreach (self::getUseStatementPointers($phpcsFile, $openTagPointer) as $usePointer) { - $pointerBeforeUseStatements = $openTagPointer; + // Get base key (namespace or open tag before this use) + $basePointerBeforeUse = $openTagPointer; if (count($namespaceAndOpenTagPointers) > 1) { foreach (array_reverse($namespaceAndOpenTagPointers) as $namespaceAndOpenTagPointer) { if ($namespaceAndOpenTagPointer < $usePointer) { - $pointerBeforeUseStatements = $namespaceAndOpenTagPointer; + $basePointerBeforeUse = $namespaceAndOpenTagPointer; break; } } } + // Determine block key: start new block if namespace changed or if there's code between uses + if ($currentBlockKey === null || $basePointerBeforeUse > $currentBlockKey) { + // First use or crossed namespace boundary + $currentBlockKey = $basePointerBeforeUse; + $previousSemicolon = null; + } elseif ($previousSemicolon !== null) { + // Check for non-contiguous block: + // - Effective code (not comments) between uses always means separate blocks + // - Comments with a blank line before the use indicate intentional separation + $effectiveToken = TokenHelper::findNextEffective($phpcsFile, $previousSemicolon + 1, $usePointer); + if ($effectiveToken !== null) { + $currentBlockKey = $previousSemicolon; + } else { + // No effective code, but check if there's a blank line before this use + // (indicating the use is intentionally separated, even if only by comments) + $pointerBeforeUse = TokenHelper::findPreviousNonWhitespace($phpcsFile, $usePointer - 1); + if ( + $pointerBeforeUse !== null + && $pointerBeforeUse !== $previousSemicolon + && $tokens[$usePointer]['line'] - $tokens[$pointerBeforeUse]['line'] > 1 + ) { + $currentBlockKey = $previousSemicolon; + } + } + } + $nextTokenFromUsePointer = TokenHelper::findNextEffective($phpcsFile, $usePointer + 1); $type = UseStatement::TYPE_CLASS; if ($tokens[$nextTokenFromUsePointer]['code'] === T_STRING) { @@ -191,7 +221,10 @@ public static function getFileUseStatements(File $phpcsFile): array $type, self::getAlias($phpcsFile, $usePointer), ); - $useStatements[$pointerBeforeUseStatements][UseStatement::getUniqueId($type, $name)] = $useStatement; + $useStatements[$currentBlockKey][UseStatement::getUniqueId($type, $name)] = $useStatement; + + // Track semicolon for next iteration + $previousSemicolon = TokenHelper::findNext($phpcsFile, T_SEMICOLON, $usePointer + 1); } return $useStatements; diff --git a/SlevomatCodingStandard/Sniffs/Namespaces/AlphabeticallySortedUsesSniff.php b/SlevomatCodingStandard/Sniffs/Namespaces/AlphabeticallySortedUsesSniff.php index d3301f9c0..a9defaae9 100644 --- a/SlevomatCodingStandard/Sniffs/Namespaces/AlphabeticallySortedUsesSniff.php +++ b/SlevomatCodingStandard/Sniffs/Namespaces/AlphabeticallySortedUsesSniff.php @@ -21,6 +21,7 @@ use function sprintf; use function uasort; use const T_COMMA; +use const T_DOC_COMMENT_OPEN_TAG; use const T_OPEN_TAG; use const T_OPEN_USE_GROUP; use const T_SEMICOLON; @@ -121,7 +122,13 @@ private function fixAlphabeticalOrder(File $phpcsFile, array $useStatements): vo $tokens = $phpcsFile->getTokens(); + // Track comments before use statements $commentsBefore = []; + // Track potential block-level comment (docblock before first use when only first has one) + $blockLevelComment = null; + $firstUseDocblockInfo = null; + + // First pass: collect all comments and detect if first use has a docblock foreach ($useStatements as $useStatement) { $pointerBeforeUseStatement = TokenHelper::findPreviousNonWhitespace($phpcsFile, $useStatement->getPointer() - 1); @@ -138,14 +145,44 @@ private function fixAlphabeticalOrder(File $phpcsFile, array $useStatements): vo ? CommentHelper::getMultilineCommentStartPointer($phpcsFile, $pointerBeforeUseStatement) : $tokens[$pointerBeforeUseStatement]['comment_opener']; + if ($firstPointer === $useStatement->getPointer()) { + $firstPointer = $commentStartPointer; + $isDocblock = $tokens[$commentStartPointer]['code'] === T_DOC_COMMENT_OPEN_TAG; + if ($isDocblock) { + // Save info for second pass - we may treat this as block-level + $firstUseDocblockInfo = [ + 'startPointer' => $commentStartPointer, + 'endPointer' => $pointerBeforeUseStatement, + 'usePointer' => $useStatement->getPointer(), + ]; + continue; + } + } + $commentsBefore[$useStatement->getPointer()] = TokenHelper::getContent( $phpcsFile, $commentStartPointer, $pointerBeforeUseStatement, ); + } - if ($firstPointer === $useStatement->getPointer()) { - $firstPointer = $commentStartPointer; + // If first use has a docblock and no other uses have comments, treat it as block-level + // (likely file-level documentation). Otherwise, it's per-use and should move with sorting. + if ($firstUseDocblockInfo !== null) { + if (count($commentsBefore) === 0) { + // Only first use has a comment - treat as block-level + $blockLevelComment = TokenHelper::getContent( + $phpcsFile, + $firstUseDocblockInfo['startPointer'], + $firstUseDocblockInfo['endPointer'], + ); + } else { + // Other uses also have comments - treat first use's docblock as per-use + $commentsBefore[$firstUseDocblockInfo['usePointer']] = TokenHelper::getContent( + $phpcsFile, + $firstUseDocblockInfo['startPointer'], + $firstUseDocblockInfo['endPointer'], + ); } } @@ -155,10 +192,19 @@ private function fixAlphabeticalOrder(File $phpcsFile, array $useStatements): vo FixerHelper::removeBetweenIncluding($phpcsFile, $firstPointer, $lastSemicolonPointer); + // Build the new content with block-level comment first, then sorted uses + $blockLevelCommentContent = ''; + if ($blockLevelComment !== null) { + $blockLevelCommentContent = $blockLevelComment; + if (!StringHelper::endsWith($blockLevelCommentContent, $phpcsFile->eolChar)) { + $blockLevelCommentContent .= $phpcsFile->eolChar; + } + } + FixerHelper::add( $phpcsFile, $firstPointer, - implode($phpcsFile->eolChar, array_map(static function (UseStatement $useStatement) use ($phpcsFile, $commentsBefore): string { + $blockLevelCommentContent . implode($phpcsFile->eolChar, array_map(static function (UseStatement $useStatement) use ($phpcsFile, $commentsBefore): string { $unqualifiedName = NamespaceHelper::getUnqualifiedNameFromFullyQualifiedName($useStatement->getFullyQualifiedTypeName()); $useTypeName = UseStatement::getTypeName($useStatement->getType()); diff --git a/tests/Helpers/UseStatementHelperTest.php b/tests/Helpers/UseStatementHelperTest.php index 1214c2ca3..3dab9d838 100644 --- a/tests/Helpers/UseStatementHelperTest.php +++ b/tests/Helpers/UseStatementHelperTest.php @@ -2,6 +2,7 @@ namespace SlevomatCodingStandard\Helpers; +use function array_values; use const T_CLASS; use const T_FUNCTION; use const T_USE; @@ -113,8 +114,11 @@ public function testGetFullyQualifiedTypeNameFromUse(): void public function testGetFileUseStatements(): void { $phpcsFile = $this->getCodeSnifferFile(__DIR__ . '/data/useStatements.php'); - $useStatements = UseStatementHelper::getFileUseStatements($phpcsFile)[0]; - self::assertCount(8, $useStatements); + $allUseStatements = UseStatementHelper::getFileUseStatements($phpcsFile); + + // First block (at open tag) + $useStatements = $allUseStatements[0]; + self::assertCount(7, $useStatements); self::assertPointer(4, $useStatements[UseStatement::getUniqueId(UseStatement::TYPE_CLASS, 'Baz')]->getPointer()); self::assertUseStatement( 'Bar\Baz', @@ -140,10 +144,14 @@ public function testGetFileUseStatements(): void false, 'LoremIpsum', ); + + // Second block (after function whatever) - Zero is in a separate block due to code between uses + $secondBlockStatements = array_values($allUseStatements)[1]; + self::assertCount(1, $secondBlockStatements); self::assertUseStatement( 'Zero', 'Zero', - $useStatements[UseStatement::getUniqueId(UseStatement::TYPE_CLASS, 'Zero')], + $secondBlockStatements[UseStatement::getUniqueId(UseStatement::TYPE_CLASS, 'Zero')], false, false, null, diff --git a/tests/Sniffs/Namespaces/AlphabeticallySortedUsesSniffTest.php b/tests/Sniffs/Namespaces/AlphabeticallySortedUsesSniffTest.php index a02dd8e2a..39d367f80 100644 --- a/tests/Sniffs/Namespaces/AlphabeticallySortedUsesSniffTest.php +++ b/tests/Sniffs/Namespaces/AlphabeticallySortedUsesSniffTest.php @@ -130,4 +130,30 @@ public function testFixableNotPsr12Compatible(): void self::assertAllFixedInFile($report); } + public function testNonContiguousUseBlocks(): void + { + $report = self::checkFile( + __DIR__ . '/data/nonContiguousUseBlocks.php', + [], + [AlphabeticallySortedUsesSniff::CODE_INCORRECT_ORDER], + ); + + // First block error (sniff returns after first error, fixer handles subsequent passes) + self::assertSniffError($report, 4, AlphabeticallySortedUsesSniff::CODE_INCORRECT_ORDER, 'A'); + + // Both blocks are sorted in the fixed output + self::assertAllFixedInFile($report); + } + + public function testFixableWithFileLevelDocblock(): void + { + $report = self::checkFile( + __DIR__ . '/data/docblockBeforeUses.php', + [], + [AlphabeticallySortedUsesSniff::CODE_INCORRECT_ORDER], + ); + // File-level docblock (only the first use has a comment) should stay at top + self::assertAllFixedInFile($report); + } + } diff --git a/tests/Sniffs/Namespaces/UseSpacingSniffTest.php b/tests/Sniffs/Namespaces/UseSpacingSniffTest.php index 931ef768e..9f4bfe1ea 100644 --- a/tests/Sniffs/Namespaces/UseSpacingSniffTest.php +++ b/tests/Sniffs/Namespaces/UseSpacingSniffTest.php @@ -208,4 +208,21 @@ public function testMoreNamespaces(): void self::assertAllFixedInFile($report); } + public function testNonContiguousBlocks(): void + { + $report = self::checkFile( + __DIR__ . '/data/useSpacingNonContiguousBlocks.php', + [], + [UseSpacingSniff::CODE_INCORRECT_LINES_COUNT_BETWEEN_SAME_TYPES_OF_USE], + ); + + // Error between A and B (contiguous block, can be fixed) + self::assertSniffError($report, 6, UseSpacingSniff::CODE_INCORRECT_LINES_COUNT_BETWEEN_SAME_TYPES_OF_USE); + + // No error between B and C - they're in separate blocks now + self::assertSame(1, $report->getErrorCount()); + + self::assertAllFixedInFile($report); + } + } diff --git a/tests/Sniffs/Namespaces/data/docblockBeforeUses.fixed.php b/tests/Sniffs/Namespaces/data/docblockBeforeUses.fixed.php new file mode 100644 index 000000000..7383bbfcc --- /dev/null +++ b/tests/Sniffs/Namespaces/data/docblockBeforeUses.fixed.php @@ -0,0 +1,11 @@ +