From 9f2a6d7f37b9d368cdf7428da0386e0ad5175952 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 23:57:53 +0200 Subject: [PATCH] Cache UseStatementSniff getUseStatements across phpcbf fix iterations UseStatementSniff has its own getUseStatements() implementation that duplicates UseStatementsTrait::getUseStatements() (cached in #64). It already has an instance-level cache via existingStatements, which covers repeated calls within a single phpcs pass; the new static cache adds coverage across phpcbf fix iterations, where populateTokenListeners() creates fresh sniff instances per pass and resets the instance cache. Cache invalidation follows the same fingerprint-based scheme as #64: token count alone is not strong enough, since an alias rename keeps it constant. Cached entries record a content fingerprint of each use statement range and re-verify them against the live tokens before being trusted. The cache also refuses to serve an empty result so it cannot return stale state for a file where a fix added a first use statement while another simultaneous fix happened to keep the file's overall token count unchanged. Measured on the same CakePHP 5 app from #62 / #63 / #64 / #65 (parallel=1, --report=performance): PhpCollective.Namespaces.UseStatement 3.36s -> 2.08s Existing test suite (100 tests / 122 assertions) passes unchanged. The FQCN cache changes from the earlier draft of this PR were dropped in favour of the cache that landed via #65. --- .../Sniffs/Namespaces/UseStatementSniff.php | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/PhpCollective/Sniffs/Namespaces/UseStatementSniff.php b/PhpCollective/Sniffs/Namespaces/UseStatementSniff.php index 35bf1d4..e666d82 100644 --- a/PhpCollective/Sniffs/Namespaces/UseStatementSniff.php +++ b/PhpCollective/Sniffs/Namespaces/UseStatementSniff.php @@ -40,6 +40,22 @@ class UseStatementSniff implements Sniff */ protected ?string $className = null; + /** + * Per-file cache of `getUseStatements()` output. + * + * The sniff's existing instance-level cache (`$this->existingStatements`) + * already covers repeated calls within a single phpcs pass. The + * static cache adds protection across phpcbf fix iterations, where + * `populateTokenListeners()` instantiates a fresh sniff object per + * pass and resets the instance cache. Token count alone is not a + * strong enough invalidation key (an alias rename can keep it + * constant), so cached entries also store a content fingerprint of + * each use statement range and re-verify it before being trusted. + * + * @var array>, fingerprints: array}> + */ + private static array $useStatementsFileCache = []; + /** * @inheritDoc */ @@ -1120,8 +1136,19 @@ protected function isSameVendor(File $phpcsFile, string $fullName): bool protected function getUseStatements(File $phpcsFile): array { $tokens = $phpcsFile->getTokens(); + $cacheKey = $phpcsFile->getFilename(); + $tokenCount = count($tokens); + if ( + isset(self::$useStatementsFileCache[$cacheKey]) + && self::$useStatementsFileCache[$cacheKey]['count'] === $tokenCount + && self::$useStatementsFileCache[$cacheKey]['fingerprints'] !== [] + && $this->useStatementsCacheStillValid(self::$useStatementsFileCache[$cacheKey]['fingerprints'], $tokens) + ) { + return self::$useStatementsFileCache[$cacheKey]['statements']; + } $statements = []; + $fingerprints = []; foreach ($tokens as $index => $token) { if ($token['code'] !== T_USE || $token['level'] > 0) { continue; @@ -1138,6 +1165,9 @@ protected function getUseStatements(File $phpcsFile): array } $semicolonIndex = $phpcsFile->findNext(T_SEMICOLON, $useStatementStartIndex + 1); + if ($semicolonIndex === false) { + continue; + } $useStatementEndIndex = $phpcsFile->findPrevious(Tokens::$emptyTokens, $semicolonIndex - 1, null, true); if ($useStatementEndIndex === false) { continue; @@ -1177,11 +1207,76 @@ protected function getUseStatements(File $phpcsFile): array 'shortName' => $shortName, 'start' => $index, ]; + $fingerprints[] = [ + 'start' => (int)$index, + 'end' => $semicolonIndex, + 'fingerprint' => $this->buildUseStatementFingerprint($tokens, (int)$index, $semicolonIndex), + ]; } + self::$useStatementsFileCache[$cacheKey] = [ + 'count' => $tokenCount, + 'statements' => $statements, + 'fingerprints' => $fingerprints, + ]; + return $statements; } + /** + * Verify a cached `getUseStatements()` entry against the live tokens. + * + * Matches the invalidation strategy used by `UseStatementsTrait` and + * `FullyQualifiedClassNameInDocBlockSniff`: re-fingerprint each + * recorded use statement range and bail if anything differs. Catches + * in-place edits that preserve token count (e.g. alias rename). + * + * Cost: O(num_uses * avg_use_length) - a handful of token reads. + * + * @param array $fingerprints + * @param array> $tokens + * + * @return bool + */ + private function useStatementsCacheStillValid(array $fingerprints, array $tokens): bool + { + foreach ($fingerprints as $entry) { + $start = $entry['start']; + if (!isset($tokens[$start]) || $tokens[$start]['code'] !== T_USE) { + return false; + } + + $live = $this->buildUseStatementFingerprint($tokens, $start, $entry['end']); + if ($live !== $entry['fingerprint']) { + return false; + } + } + + return true; + } + + /** + * Concatenate the content of every token in [$start, $end] inclusive. + * + * @param array> $tokens + * @param int $start + * @param int $end + * + * @return string + */ + private function buildUseStatementFingerprint(array $tokens, int $start, int $end): string + { + $fingerprint = ''; + for ($i = $start; $i <= $end; $i++) { + if (!isset($tokens[$i])) { + break; + } + $fingerprint .= $tokens[$i]['content']; + } + + return $fingerprint; + } + /** * @param \PHP_CodeSniffer\Files\File $phpcsFile * @param string $shortName