diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 0c3c293f..d0894506 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -20,7 +20,6 @@ use function array_fill_keys; use function array_is_list; -use function array_key_exists; use function array_keys; use function array_map; use function array_values; @@ -66,7 +65,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 7; + public const FORMAT_VERSION = 8; private readonly string $cacheDirectory; @@ -1323,20 +1322,24 @@ private function enumCaseNodeFromArray(array $enumCase): ?EnumCaseNode /** * The file is not stored: the payload belongs to one file, known when - * loading. The two lists are empty for most files and left out. + * loading. Common scalars use a fixed positional list; append new fields + * to that list and bump FORMAT_VERSION. The two optional lists are empty + * for most files and left out. * * @return array */ private function fileAnalysisToArray(FileAnalysis $fileAnalysis): array { $analysis = [ - 'hasUtf8Bom' => $fileAnalysis->hasUtf8Bom, - 'hasValidUtf8' => $fileAnalysis->hasValidUtf8, - 'invalidPhpTagLine' => $fileAnalysis->invalidPhpTagLine, - 'hasValidAst' => $fileAnalysis->hasValidAst, - 'declaresSymbols' => $fileAnalysis->declaresSymbols, - 'hasSideEffects' => $fileAnalysis->hasSideEffects, - 'sideEffectLine' => $fileAnalysis->sideEffectLine, + 'scalars' => [ + $fileAnalysis->hasUtf8Bom, + $fileAnalysis->hasValidUtf8, + $fileAnalysis->invalidPhpTagLine, + $fileAnalysis->hasValidAst, + $fileAnalysis->declaresSymbols, + $fileAnalysis->hasSideEffects, + $fileAnalysis->sideEffectLine, + ], ]; if ($fileAnalysis->nonCanonicalKeywordConstants !== []) { @@ -1353,18 +1356,33 @@ private function fileAnalysisToArray(FileAnalysis $fileAnalysis): array /** @param array $analysis */ private function fileAnalysisFromArray(array $analysis, string $file): ?FileAnalysis { + $scalars = $analysis['scalars'] ?? null; + + if (! is_array($scalars) || count($scalars) !== 7 || ! array_is_list($scalars)) { + return null; + } + + [ + $hasUtf8Bom, + $hasValidUtf8, + $invalidPhpTagLine, + $hasValidAst, + $declaresSymbols, + $hasSideEffects, + $sideEffectLine, + ] = $scalars; + $nonCanonicalKeywordConstants = $analysis['nonCanonicalKeywordConstants'] ?? []; $numericLiterals = $analysis['numericLiterals'] ?? []; if ( - ! is_bool($analysis['hasUtf8Bom'] ?? null) - || ! is_bool($analysis['hasValidUtf8'] ?? null) - || ! array_key_exists('invalidPhpTagLine', $analysis) - || ($analysis['invalidPhpTagLine'] !== null && ! is_int($analysis['invalidPhpTagLine'])) - || ! is_bool($analysis['hasValidAst'] ?? null) - || ! is_bool($analysis['declaresSymbols'] ?? null) - || ! is_bool($analysis['hasSideEffects'] ?? null) - || ! is_int($analysis['sideEffectLine'] ?? null) + ! is_bool($hasUtf8Bom) + || ! is_bool($hasValidUtf8) + || ($invalidPhpTagLine !== null && ! is_int($invalidPhpTagLine)) + || ! is_bool($hasValidAst) + || ! is_bool($declaresSymbols) + || ! is_bool($hasSideEffects) + || ! is_int($sideEffectLine) || ! $this->isKeywordConstantList($nonCanonicalKeywordConstants) || ! $this->isNumericLiteralList($numericLiterals) ) { @@ -1373,13 +1391,13 @@ private function fileAnalysisFromArray(array $analysis, string $file): ?FileAnal return new FileAnalysis( file: $file, - hasUtf8Bom: $analysis['hasUtf8Bom'], - hasValidUtf8: $analysis['hasValidUtf8'], - invalidPhpTagLine: $analysis['invalidPhpTagLine'], - hasValidAst: $analysis['hasValidAst'], - declaresSymbols: $analysis['declaresSymbols'], - hasSideEffects: $analysis['hasSideEffects'], - sideEffectLine: $analysis['sideEffectLine'], + hasUtf8Bom: $hasUtf8Bom, + hasValidUtf8: $hasValidUtf8, + invalidPhpTagLine: $invalidPhpTagLine, + hasValidAst: $hasValidAst, + declaresSymbols: $declaresSymbols, + hasSideEffects: $hasSideEffects, + sideEffectLine: $sideEffectLine, nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, numericLiterals: $numericLiterals, ); diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index ae31bd73..74a9e4c6 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -1646,60 +1646,153 @@ public function testClassNodesWithFileAnalysisMissesLegacyEntryWithoutFileFacts( } /** - * @return iterable}> + * @return iterable, list}> */ - public static function malformedFileAnalysisProvider(): iterable - { - $valid = [ - 'file' => __FILE__, - 'hasUtf8Bom' => false, - 'hasValidUtf8' => true, - 'invalidPhpTagLine' => null, - 'hasValidAst' => true, - 'declaresSymbols' => true, - 'hasSideEffects' => false, - 'sideEffectLine' => 1, - 'nonCanonicalKeywordConstants' => [], - 'numericLiterals' => [], - ]; + public static function optionalFileAnalysisListsProvider(): iterable + { + yield 'both omitted' => [[], []]; + yield 'only keyword constants' => [[[3, 'TRUE'], [5, '\\NULL']], []]; + yield 'only numeric literals' => [[], [[7, '10000', 10000], [8, '1.25', 1.25]]]; + yield 'both present' => [[[3, 'TRUE']], [[7, '10000', 10000]]]; + } + + /** + * @param list $nonCanonicalKeywordConstants + * @param list $numericLiterals + */ + #[DataProvider('optionalFileAnalysisListsProvider')] + public function testFileAnalysisUsesCompactScalarsAndSparseLists( + array $nonCanonicalKeywordConstants, + array $numericLiterals, + ): void { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + $fileAnalysis = new FileAnalysis( + file: $sourceFile, + hasUtf8Bom: true, + hasValidUtf8: false, + invalidPhpTagLine: 3, + hasValidAst: false, + declaresSymbols: false, + hasSideEffects: true, + sideEffectLine: 5, + nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, + numericLiterals: $numericLiterals, + ); + + file_put_contents($sourceFile, 'storeAnalysisNodes($sourceFile, 'config', [], $fileAnalysis); + + $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($payload); + $expected = ['scalars' => [true, false, 3, false, false, true, 5]]; + + if ($nonCanonicalKeywordConstants !== []) { + $expected['nonCanonicalKeywordConstants'] = $nonCanonicalKeywordConstants; + } + + if ($numericLiterals !== []) { + $expected['numericLiterals'] = $numericLiterals; + } + + $this->assertSame($expected, $payload['fileAnalysis']); + + $nextRunCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + $loaded = $nextRunCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config'); + $this->assertNotNull($loaded); + $this->assertEquals($fileAnalysis, $loaded['fileAnalysis']); + $this->assertSame($sourceFile, $loaded['fileAnalysis']->file); + $this->assertSame($nonCanonicalKeywordConstants, $loaded['fileAnalysis']->nonCanonicalKeywordConstants); + $this->assertSame($numericLiterals, $loaded['fileAnalysis']->numericLiterals); + + // Explicit empty lists hydrate just like omitted lists. + $payload['fileAnalysis'] = $expected + ['nonCanonicalKeywordConstants' => [], 'numericLiterals' => []]; + $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); + $this->assertEquals($loaded, $nextRunCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); + } finally { + unlink($sourceFile); + $this->removeTempDirectory($cacheDirectory); + } + } + + /** + * @return iterable + */ + private function malformedFileAnalyses(): iterable + { + $scalars = [false, true, null, true, true, false, 1]; + $valid = ['scalars' => $scalars]; + yield 'null facts' => [null]; + yield 'string facts' => ['bad']; + yield 'boolean facts' => [false]; + yield 'integer facts' => [1]; + yield 'missing scalars' => [[]]; yield 'numeric keys' => [[0 => 'bad']]; - yield 'invalid BOM flag' => [[...$valid, 'hasUtf8Bom' => 'bad']]; - yield 'invalid UTF-8 flag' => [[...$valid, 'hasValidUtf8' => 'bad']]; - yield 'missing invalid tag line' => [ + yield 'bare scalar list' => [$scalars]; + yield 'null scalars' => [['scalars' => null]]; + yield 'string scalars' => [['scalars' => 'bad']]; + yield 'empty scalar list' => [['scalars' => []]]; + yield 'missing scalar entry' => [['scalars' => [false, true, null, true, true, false]]]; + yield 'extra scalar entry' => [['scalars' => [...$scalars, 2]]]; + yield 'scalar list with gap' => [['scalars' => [false, true, null, true, true, false, 7 => 1]]]; + yield 'scalar list with string key' => [ + ['scalars' => [false, true, null, true, true, false, 'sideEffectLine' => 1]], + ]; + yield 'scalar list with reordered keys' => [['scalars' => [1 => true, 0 => false] + $scalars]]; + yield 'legacy associative scalars' => [ [ - 'file' => __FILE__, - 'hasUtf8Bom' => false, - 'hasValidUtf8' => true, - 'hasValidAst' => true, - 'declaresSymbols' => true, - 'hasSideEffects' => false, - 'sideEffectLine' => 1, + 'hasUtf8Bom' => false, + 'hasValidUtf8' => true, + 'invalidPhpTagLine' => null, + 'hasValidAst' => true, + 'declaresSymbols' => true, + 'hasSideEffects' => false, + 'sideEffectLine' => 1, ], ]; - yield 'invalid tag line type' => [[...$valid, 'invalidPhpTagLine' => 'bad']]; - yield 'invalid AST flag' => [[...$valid, 'hasValidAst' => 'bad']]; - yield 'invalid declaration flag' => [[...$valid, 'declaresSymbols' => 'bad']]; - yield 'invalid side-effects flag' => [[...$valid, 'hasSideEffects' => 'bad']]; - yield 'invalid side-effect line' => [[...$valid, 'sideEffectLine' => 'bad']]; + + foreach ([0, 1, 3, 4, 5] as $index) { + foreach (['string' => 'true', 'integer' => 1, 'null' => null, 'array' => []] as $type => $value) { + $invalid = $scalars; + $invalid[$index] = $value; + yield 'boolean scalar ' . $index . ' with ' . $type => [['scalars' => $invalid]]; + } + } + + foreach ([2, 6] as $index) { + foreach (['string' => '1', 'float' => 1.5, 'boolean' => false, 'array' => []] as $type => $value) { + $invalid = $scalars; + $invalid[$index] = $value; + yield 'line scalar ' . $index . ' with ' . $type => [['scalars' => $invalid]]; + } + } + + yield 'null side-effect line' => [['scalars' => [false, true, null, true, true, false, null]]]; yield 'invalid keyword constants type' => [[...$valid, 'nonCanonicalKeywordConstants' => 'bad']]; yield 'keyword constants not a list' => [[...$valid, 'nonCanonicalKeywordConstants' => ['a' => [1, 'TRUE']]]]; + yield 'keyword constant not an array' => [[...$valid, 'nonCanonicalKeywordConstants' => ['bad']]]; yield 'keyword constant not a pair' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1]]]]; yield 'keyword constant with extra entry' => [ [...$valid, 'nonCanonicalKeywordConstants' => [[1, 'TRUE', 'extra']]], ]; yield 'keyword constant with invalid line' => [[...$valid, 'nonCanonicalKeywordConstants' => [['1', 'TRUE']]]]; yield 'keyword constant with invalid spelling' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1, 1]]]]; + yield 'invalid numeric literals type' => [[...$valid, 'numericLiterals' => 'bad']]; yield 'numeric literals not a list' => [[...$valid, 'numericLiterals' => ['bad' => [1, '10000', 10000]]]]; + yield 'numeric literal not an array' => [[...$valid, 'numericLiterals' => ['bad']]]; yield 'numeric literal not a triple' => [[...$valid, 'numericLiterals' => [[1, '10000']]]]; + yield 'numeric literal with extra entry' => [[...$valid, 'numericLiterals' => [[1, '10000', 10000, 'extra']]]]; yield 'numeric literal with invalid line' => [[...$valid, 'numericLiterals' => [['1', '10000', 10000]]]]; yield 'numeric literal with invalid spelling' => [[...$valid, 'numericLiterals' => [[1, 10000, 10000]]]]; yield 'numeric literal with invalid value' => [[...$valid, 'numericLiterals' => [[1, '10000', '10000']]]]; } - /** @param array $fileAnalysis */ - #[DataProvider('malformedFileAnalysisProvider')] - public function testClassNodesWithFileAnalysisMissesMalformedFacts(array $fileAnalysis): void + public function testClassNodesWithFileAnalysisMissesMalformedFacts(): void { $cacheDirectory = $this->createTempDirectory(); $sourceFile = $cacheDirectory . '/Foo.php'; @@ -1718,10 +1811,16 @@ public function testClassNodesWithFileAnalysisMissesMalformedFacts(array $fileAn $cacheFile = $this->firstJsonFile($cacheDirectory); $payload = json_decode((string) file_get_contents($cacheFile), true, 512, JSON_THROW_ON_ERROR); $this->assertIsArray($payload); - $payload['fileAnalysis'] = $fileAnalysis; - $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); - $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); + foreach ($this->malformedFileAnalyses() as $description => [$fileAnalysis]) { + $payload['fileAnalysis'] = $fileAnalysis; + $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); + + $this->assertNull( + $analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config'), + $description, + ); + } } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory);