diff --git a/PhpCollective/Sniffs/WhiteSpace/ConsistentIndentSniff.php b/PhpCollective/Sniffs/WhiteSpace/ConsistentIndentSniff.php index 1d328bb..2f6446d 100644 --- a/PhpCollective/Sniffs/WhiteSpace/ConsistentIndentSniff.php +++ b/PhpCollective/Sniffs/WhiteSpace/ConsistentIndentSniff.php @@ -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; } @@ -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}> + */ + 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 @@ -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> $tokens + * + * @return array + */ + 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; } /** @@ -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> $tokens @@ -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; } } @@ -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 @@ -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;