Skip to content
Draft
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
39 changes: 36 additions & 3 deletions SlevomatCodingStandard/Helpers/UseStatementHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand All @@ -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'],
);
}
}

Expand All @@ -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());
Expand Down
14 changes: 11 additions & 3 deletions tests/Helpers/UseStatementHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace SlevomatCodingStandard\Helpers;

use function array_values;
use const T_CLASS;
use const T_FUNCTION;
use const T_USE;
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions tests/Sniffs/Namespaces/AlphabeticallySortedUsesSniffTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
17 changes: 17 additions & 0 deletions tests/Sniffs/Namespaces/UseSpacingSniffTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
11 changes: 11 additions & 0 deletions tests/Sniffs/Namespaces/data/docblockBeforeUses.fixed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

/** @package Patient Portal
*
* From phreeze package
*
*/
use OpenEMR\Common\Session\SessionWrapperFactory;
use OpenEMR\Core\OEGlobalsBag;

require_once(__DIR__ . "/../../vendor/autoload.php");
11 changes: 11 additions & 0 deletions tests/Sniffs/Namespaces/data/docblockBeforeUses.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

/** @package Patient Portal
*
* From phreeze package
*
*/
use OpenEMR\Core\OEGlobalsBag;
use OpenEMR\Common\Session\SessionWrapperFactory;

require_once(__DIR__ . "/../../vendor/autoload.php");
10 changes: 10 additions & 0 deletions tests/Sniffs/Namespaces/data/nonContiguousUseBlocks.fixed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

use A;
use B;

// This code should NOT be deleted
$x = 1;

use C;
use D;
10 changes: 10 additions & 0 deletions tests/Sniffs/Namespaces/data/nonContiguousUseBlocks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

use B;
use A;

// This code should NOT be deleted
$x = 1;

use D;
use C;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

use A;
use B;

// This code should NOT be deleted
$x = 1;

use C;
11 changes: 11 additions & 0 deletions tests/Sniffs/Namespaces/data/useSpacingNonContiguousBlocks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

use A;


use B;

// This code should NOT be deleted
$x = 1;

use C;
Loading