Skip to content
Merged
Show file tree
Hide file tree
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
68 changes: 43 additions & 25 deletions src/Cache/AnalysisResultCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<string, mixed>
*/
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 !== []) {
Expand All @@ -1353,18 +1356,33 @@ private function fileAnalysisToArray(FileAnalysis $fileAnalysis): array
/** @param array<mixed, mixed> $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)
) {
Expand All @@ -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,
);
Expand Down
171 changes: 135 additions & 36 deletions tests/Cache/AnalysisResultCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1646,60 +1646,153 @@ public function testClassNodesWithFileAnalysisMissesLegacyEntryWithoutFileFacts(
}

/**
* @return iterable<string, array{array<mixed, mixed>}>
* @return iterable<string, array{list<array{int, string}>, list<array{int, string, int|float}>}>
*/
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<array{int, string}> $nonCanonicalKeywordConstants
* @param list<array{int, string, int|float}> $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, '<?php class Foo {}');

try {
$analysisResultCache->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<string, array{mixed}>
*/
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<mixed, mixed> $fileAnalysis */
#[DataProvider('malformedFileAnalysisProvider')]
public function testClassNodesWithFileAnalysisMissesMalformedFacts(array $fileAnalysis): void
public function testClassNodesWithFileAnalysisMissesMalformedFacts(): void
{
$cacheDirectory = $this->createTempDirectory();
$sourceFile = $cacheDirectory . '/Foo.php';
Expand All @@ -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);
Expand Down