From 4fabc8a3758aade06cbf16da58cac99fbc0cf076 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 20:39:53 +0200 Subject: [PATCH] Cache class name resolution and skip redundant return-type body scans AbstractSniff::getClassNameWithNamespace() and getClassName() walk the token stream backwards from the last token of the file on every call, and are invoked by several sniffs once per T_FUNCTION (MethodTypeHintSniff, ReturnTypeHintSniff, DocBlockReturnSelfSniff, FullyQualifiedClassNameInDocBlockSniff, Psr4Sniff). On large files with many methods this becomes O(methods * file_size). Cache the result by filename so each file resolves at most once. DocBlockReturnSelfSniff::process() called getReturnTypes() unconditionally for every method with a return annotation. getReturnTypes() walks the entire function body looking for return statements. Since the three assertions guarded by it only fire when the annotation contains 'self', '$this', or the own class name, add a cheap pre-filter that skips the body scan for plain types like 'void', 'int', 'string', 'array<...>', etc. Measured on a real-world ~11k-line CakePHP controller with ~100 actions: parallel=1, single file: 40s -> 20s (-50%) parallel=16, whole codebase: 128s -> 90s (-30%) Total CPU across all workers: 355s -> 277s (-22%) Existing test suite (100 tests / 122 assertions) passes unchanged. --- .../Sniffs/AbstractSniffs/AbstractSniff.php | 43 ++++++++++++++++--- .../Commenting/DocBlockReturnSelfSniff.php | 24 +++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/PhpCollective/Sniffs/AbstractSniffs/AbstractSniff.php b/PhpCollective/Sniffs/AbstractSniffs/AbstractSniff.php index 9e250cd..c4e5e28 100644 --- a/PhpCollective/Sniffs/AbstractSniffs/AbstractSniff.php +++ b/PhpCollective/Sniffs/AbstractSniffs/AbstractSniff.php @@ -28,6 +28,27 @@ abstract class AbstractSniff implements Sniff '@noinspection', ]; + /** + * Per-file cache for class-name-with-namespace resolution. + * + * `getClassNameWithNamespace()` walks the token stream backwards from + * the end of the file to find the class/trait/interface/enum keyword. + * The result is stable for the lifetime of a phpcs run on a given file, + * but the original implementation re-computed it on every sniff call. + * Caching by filename turns dozens-to-hundreds of full-file walks per + * large file into a single walk. + * + * @var array + */ + private static array $classNameWithNamespaceCache = []; + + /** + * Per-file cache for class name resolution (with filename fallback). + * + * @var array + */ + private static array $classNameCache = []; + /** * @param \PHP_CodeSniffer\Files\File $phpcsFile * @param int $stackPtr @@ -98,22 +119,27 @@ protected function getNamespace(File $phpcsFile): string */ protected function getClassNameWithNamespace(File $phpcsFile): ?string { + $cacheKey = $phpcsFile->getFilename(); + if (array_key_exists($cacheKey, self::$classNameWithNamespaceCache)) { + return self::$classNameWithNamespaceCache[$cacheKey]; + } + try { $lastToken = TokenHelper::getLastTokenPointer($phpcsFile); } catch (EmptyFileException $e) { - return null; + return self::$classNameWithNamespaceCache[$cacheKey] = null; } if (!NamespaceHelper::findCurrentNamespaceName($phpcsFile, $lastToken)) { - return null; + return self::$classNameWithNamespaceCache[$cacheKey] = null; } $prevIndex = $phpcsFile->findPrevious([T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM], $lastToken); if (!$prevIndex) { - return null; + return self::$classNameWithNamespaceCache[$cacheKey] = null; } - return ClassHelper::getFullyQualifiedName( + return self::$classNameWithNamespaceCache[$cacheKey] = ClassHelper::getFullyQualifiedName( $phpcsFile, $prevIndex, ); @@ -126,10 +152,15 @@ protected function getClassNameWithNamespace(File $phpcsFile): ?string */ protected function getClassName(File $phpcsFile): string { + $cacheKey = $phpcsFile->getFilename(); + if (isset(self::$classNameCache[$cacheKey])) { + return self::$classNameCache[$cacheKey]; + } + $namespace = $this->getClassNameWithNamespace($phpcsFile); if ($namespace) { - return trim($namespace, '\\'); + return self::$classNameCache[$cacheKey] = trim($namespace, '\\'); } $fileName = $phpcsFile->getFilename(); @@ -143,7 +174,7 @@ protected function getClassName(File $phpcsFile): string $className = implode('\\', $classNameParts); $className = str_replace('.php', '', $className); - return $className; + return self::$classNameCache[$cacheKey] = $className; } /** diff --git a/PhpCollective/Sniffs/Commenting/DocBlockReturnSelfSniff.php b/PhpCollective/Sniffs/Commenting/DocBlockReturnSelfSniff.php index 8ab8902..8621c1b 100644 --- a/PhpCollective/Sniffs/Commenting/DocBlockReturnSelfSniff.php +++ b/PhpCollective/Sniffs/Commenting/DocBlockReturnSelfSniff.php @@ -43,6 +43,8 @@ public function process(File $phpcsFile, $stackPointer): void $docBlockStartIndex = $tokens[$docBlockEndIndex]['comment_opener']; + $ownClassName = null; + for ($i = $docBlockStartIndex + 1; $i < $docBlockEndIndex; $i++) { if ($tokens[$i]['type'] !== 'T_DOC_COMMENT_TAG') { continue; @@ -75,6 +77,28 @@ public function process(File $phpcsFile, $stackPointer): void } $parts = explode('|', $content); + + // The three assertions below all act only when `parts` contains + // `self`, `$this`, or the own (fully qualified) class name. + // Skipping the expensive return-type body scan when none of these + // are present turns this sniff from O(methods * body_size) into + // ~O(methods) for files where most @return tags are plain types + // like `void`, `int`, `array<...>`, etc. + if ($ownClassName === null) { + $ownClassName = '\\' . $this->getClassName($phpcsFile); + } + $needsReturnTypeCheck = false; + foreach ($parts as $part) { + if ($part === 'self' || $part === '$this' || $part === $ownClassName) { + $needsReturnTypeCheck = true; + + break; + } + } + if (!$needsReturnTypeCheck) { + continue; + } + $returnTypes = $this->getReturnTypes($phpcsFile, $stackPointer); $this->assertCorrectDocBlockParts($phpcsFile, $classNameIndex, $parts, $returnTypes, $appendix);