Skip to content
Merged
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
155 changes: 97 additions & 58 deletions PhpCollective/Sniffs/WhiteSpace/ConsistentIndentSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ public function process(File $phpcsFile, $stackPtr): void
return;
}

// Additional safety: if this looks like it could be in a case block, skip it
// Case blocks don't show up in conditions, so expected might be wrong
// Additional safety: if this looks like it could be in a case block, skip it.
if ($this->couldBeInCaseBlock($phpcsFile, $stackPtr, $tokens)) {
return;
}
Expand Down Expand Up @@ -307,8 +306,32 @@ protected function isValidContinuation(int $prevToken, array $tokens): bool
}

/**
* Check if the current position is inside a closure/anonymous function.
* PHPCS doesn't properly track closures in the conditions array, so we need to detect them manually.
* Per-file cache of arrow function (T_FN) scope ranges.
*
* T_FN, unlike T_CLOSURE, isn't propagated into inner tokens' `conditions`
* map by phpcs, so we can't use the O(depth) conditions check for arrow
* functions. Instead we collect every arrow function's [scope_opener,
* scope_closer] range once per file and check each line against this list.
* Arrow function scopes can legitimately be long (think a multi-line
* `match` expression or a deeply nested array literal as the body), so a
* scan window would be incorrect; the cached-ranges approach is both
* unbounded and faster.
*
* The cache stores the token count alongside the scopes so it self-
* invalidates if the file is re-tokenized during a phpcbf fix loop.
*
* @var array<string, array{count: int, scopes: array<int, array{0: int, 1: int}>}>
*/
private static array $arrowFunctionScopesCache = [];

/**
* Check if the current position is inside a closure or arrow function.
*
* Fast path: phpcs's PHP tokenizer rewrites the `conditions` map of every
* token inside an anonymous function so it contains `T_CLOSURE` rather
* than `T_FUNCTION` (see `PHP::processAdditional()`), so the common case
* resolves with an O(depth) walk of the (typically <10-entry) conditions
* array. T_FN is handled via a per-file scope cache built on first use.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile
* @param int $stackPtr
Expand All @@ -318,45 +341,63 @@ protected function isValidContinuation(int $prevToken, array $tokens): bool
*/
protected function isInsideClosure(File $phpcsFile, int $stackPtr, array $tokens): bool
{
// Look backward for a closure token (T_CLOSURE, T_FN, or T_FUNCTION for anonymous functions)
$closureTypes = [T_CLOSURE, T_FN];

// Search backward for a closure opening
for ($i = $stackPtr - 1; $i >= 0; $i--) {
// Check for T_FUNCTION (could be a named method or anonymous function)
if ($tokens[$i]['code'] === T_FUNCTION) {
// Check if this is a named function (not a closure)
$nextNonWhitespace = $phpcsFile->findNext(T_WHITESPACE, $i + 1, null, true);
if ($nextNonWhitespace !== false && $tokens[$nextNonWhitespace]['code'] === T_STRING) {
// Named function/method - stop searching, we're not in a closure
return false;
}

// It's an anonymous function (closure) - check if we're inside it
if (isset($tokens[$i]['scope_opener']) && isset($tokens[$i]['scope_closer'])) {
if ($stackPtr > $tokens[$i]['scope_opener'] && $stackPtr < $tokens[$i]['scope_closer']) {
return true;
}
if (!empty($tokens[$stackPtr]['conditions'])) {
foreach ($tokens[$stackPtr]['conditions'] as $code) {
if ($code === T_CLOSURE) {
return true;
}
}
}

if (in_array($tokens[$i]['code'], $closureTypes, true)) {
// Found a closure, check if our position is within its scope
if (isset($tokens[$i]['scope_opener']) && isset($tokens[$i]['scope_closer'])) {
if ($stackPtr > $tokens[$i]['scope_opener'] && $stackPtr < $tokens[$i]['scope_closer']) {
return true;
}
}
foreach ($this->getArrowFunctionScopes($phpcsFile, $tokens) as $range) {
if ($stackPtr > $range[0] && $stackPtr < $range[1]) {
return true;
}
}

// Stop searching if we've gone too far (more than 2000 tokens back)
// Large closures with validation logic can easily exceed 500 tokens
if ($stackPtr - $i > 2000) {
break;
return false;
}

/**
* Build (and cache per file) the list of arrow function scope ranges.
*
* Single O(file) walk on first invocation per file, then O(arrows) per
* lookup. Arrow function counts are typically a handful per file, so the
* per-lookup cost stays small.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile
* @param array<int, array<string, mixed>> $tokens
*
* @return array<int, array{0: int, 1: int}>
*/
protected function getArrowFunctionScopes(File $phpcsFile, array $tokens): array
{
$cacheKey = $phpcsFile->getFilename();
$tokenCount = count($tokens);
if (
isset(self::$arrowFunctionScopesCache[$cacheKey])
&& self::$arrowFunctionScopesCache[$cacheKey]['count'] === $tokenCount
) {
return self::$arrowFunctionScopesCache[$cacheKey]['scopes'];
}

$scopes = [];
foreach ($tokens as $token) {
if ($token['code'] !== T_FN) {
continue;
}
if (!isset($token['scope_opener'], $token['scope_closer'])) {
continue;
}
$scopes[] = [$token['scope_opener'], $token['scope_closer']];
}

return false;
self::$arrowFunctionScopesCache[$cacheKey] = [
'count' => $tokenCount,
'scopes' => $scopes,
];

return $scopes;
}

/**
Expand Down Expand Up @@ -416,6 +457,10 @@ protected function isInsideArray(File $phpcsFile, int $stackPtr, array $tokens):
* Check if the current position is inside a switch/case block.
* Case blocks have special indentation rules per PER Coding Style.
*
* Uses the pre-computed `conditions` map: T_SWITCH is a scope opener
* in phpcs's PHP tokenizer, so every token inside a switch's scope
* already carries T_SWITCH in its conditions array — no scan needed.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile
* @param int $stackPtr
* @param array<int, array<string, mixed>> $tokens
Expand All @@ -424,20 +469,13 @@ protected function isInsideArray(File $phpcsFile, int $stackPtr, array $tokens):
*/
protected function isInsideSwitchCase(File $phpcsFile, int $stackPtr, array $tokens): bool
{
// Look backward for a switch statement
for ($i = $stackPtr - 1; $i >= 0; $i--) {
if ($tokens[$i]['code'] === T_SWITCH) {
// Found a switch, check if we're within its scope
if (isset($tokens[$i]['scope_opener']) && isset($tokens[$i]['scope_closer'])) {
if ($stackPtr > $tokens[$i]['scope_opener'] && $stackPtr < $tokens[$i]['scope_closer']) {
return true;
}
}
}
if (empty($tokens[$stackPtr]['conditions'])) {
return false;
}

// Stop if we've gone too far back
if ($stackPtr - $i > 500) {
break;
foreach ($tokens[$stackPtr]['conditions'] as $code) {
if ($code === T_SWITCH) {
return true;
}
}

Expand Down Expand Up @@ -514,7 +552,11 @@ protected function isInMultiLineConstruct(File $phpcsFile, int $stackPtr, array
}

/**
* Check if this could be in a case block by looking for case/default keywords nearby.
* Check if this could be in a case block.
*
* Uses the pre-computed `conditions` map: T_CASE and T_DEFAULT are scope
* openers in phpcs's PHP tokenizer, so a token lexically inside a case
* arm already carries T_CASE / T_DEFAULT in its conditions array.
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile
* @param int $stackPtr
Expand All @@ -524,17 +566,14 @@ protected function isInMultiLineConstruct(File $phpcsFile, int $stackPtr, array
*/
protected function couldBeInCaseBlock(File $phpcsFile, int $stackPtr, array $tokens): bool
{
// Look backward for case/default keywords (not too far)
// Using 500 to match isInsideSwitchCase() limit, as case blocks can be large
for ($i = $stackPtr - 1; $i >= max(0, $stackPtr - 500); $i--) {
if ($tokens[$i]['code'] === T_CASE || $tokens[$i]['code'] === T_DEFAULT) {
// Found a case/default, likely in a case block
if (empty($tokens[$stackPtr]['conditions'])) {
return false;
}

foreach ($tokens[$stackPtr]['conditions'] as $code) {
if ($code === T_CASE || $code === T_DEFAULT) {
return true;
}
// If we hit another control structure, stop
if ($tokens[$i]['code'] === T_IF || $tokens[$i]['code'] === T_WHILE || $tokens[$i]['code'] === T_FOR) {
break;
}
}

return false;
Expand Down
Loading